groovy-commits mailing list archives

Site index · List index
Message view « Date » · « Thread »
Top « Date » · « Thread »
From pa...@apache.org
Subject [2/2] groovy git commit: GROOVY-8520 add picocli-based CliBuilder (closes #688)
Date Fri, 27 Apr 2018 22:33:31 GMT
GROOVY-8520 add picocli-based CliBuilder (closes #688)


Project: http://git-wip-us.apache.org/repos/asf/groovy/repo
Commit: http://git-wip-us.apache.org/repos/asf/groovy/commit/0803166c
Tree: http://git-wip-us.apache.org/repos/asf/groovy/tree/0803166c
Diff: http://git-wip-us.apache.org/repos/asf/groovy/diff/0803166c

Branch: refs/heads/GROOVY_2_5_X
Commit: 0803166cd089471ecedd82c6fb4a64fca0a2c7e1
Parents: 582feba
Author: Remko Popma <remkop@yahoo.com>
Authored: Sat Apr 21 01:01:02 2018 +0200
Committer: Paul King <paulk@asert.com.au>
Committed: Sat Apr 28 08:04:50 2018 +1000

----------------------------------------------------------------------
 build.gradle                                    |    2 +
 gradle/binarycompatibility.gradle               |    2 +-
 settings.gradle                                 |    1 +
 src/spec/assets/img/usageMessageSpec.png        |  Bin 0 -> 8151 bytes
 .../doc/core-domain-specific-languages.adoc     |  117 +-
 subprojects/groovy-cli-picocli/build.gradle     |   25 +
 .../groovy/groovy/cli/picocli/CliBuilder.groovy | 1028 ++++++++++++++++++
 .../src/spec/test/builder/CliBuilderTest.groovy |  451 ++++++++
 .../groovy/cli/picocli/CliBuilderTest.groovy    | 1002 +++++++++++++++++
 subprojects/groovy-docgenerator/build.gradle    |    1 +
 subprojects/groovy-groovydoc/build.gradle       |    1 +
 11 files changed, 2621 insertions(+), 9 deletions(-)
----------------------------------------------------------------------


http://git-wip-us.apache.org/repos/asf/groovy/blob/0803166c/build.gradle
----------------------------------------------------------------------
diff --git a/build.gradle b/build.gradle
index 7da249c..1bf38df 100644
--- a/build.gradle
+++ b/build.gradle
@@ -159,6 +159,7 @@ ext {
     luceneVersion = '4.7.2'
     openbeansVersion = '1.0'
     openejbVersion = '1.0'
+    picocliVersion = '3.0.0-beta-2'
     qdoxVersion = '1.12.1'
     slf4jVersion = '1.7.21'
     xmlunitVersion = '1.6'
@@ -177,6 +178,7 @@ dependencies {
     compile "org.ow2.asm:asm-util:$asmVersion"
 
     compile "commons-cli:commons-cli:$commonsCliVersion"
+    compile "info.picocli:picocli:$picocliVersion"
     compile "org.apache.ant:ant:$antVersion"
     compile("com.thoughtworks.xstream:xstream:$xstreamVersion") {
         exclude(group: 'xpp3', module: 'xpp3_min')

http://git-wip-us.apache.org/repos/asf/groovy/blob/0803166c/gradle/binarycompatibility.gradle
----------------------------------------------------------------------
diff --git a/gradle/binarycompatibility.gradle b/gradle/binarycompatibility.gradle
index 998e26b..43713aa 100644
--- a/gradle/binarycompatibility.gradle
+++ b/gradle/binarycompatibility.gradle
@@ -35,7 +35,7 @@ task checkBinaryCompatibility {
 check.dependsOn(checkBinaryCompatibility)
 
 // for comparing between versions with different modules, set excludeModules to differing modules, e.g.
-def excludeModules = ['groovy-cli-commons', 'groovy-dateutil', 'groovy-datetime', 'performance', 'groovy-macro', 'tests-vm8']
+def excludeModules = ['groovy-cli-picocli', 'groovy-cli-commons', 'groovy-dateutil', 'groovy-datetime', 'performance', 'groovy-macro', 'tests-vm8']
 //def excludeModules = []
 
 Set projectsToCheck = allprojects.findAll{ !(it.name in excludeModules) }

http://git-wip-us.apache.org/repos/asf/groovy/blob/0803166c/settings.gradle
----------------------------------------------------------------------
diff --git a/settings.gradle b/settings.gradle
index 5145623..651936d 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -19,6 +19,7 @@
 def subprojects = ['groovy-ant',
         'groovy-bsf',
         'groovy-cli-commons',
+        'groovy-cli-picocli',
         'groovy-console',
         'groovy-dateutil',
         'groovy-docgenerator',

http://git-wip-us.apache.org/repos/asf/groovy/blob/0803166c/src/spec/assets/img/usageMessageSpec.png
----------------------------------------------------------------------
diff --git a/src/spec/assets/img/usageMessageSpec.png b/src/spec/assets/img/usageMessageSpec.png
new file mode 100644
index 0000000..09e2c09
Binary files /dev/null and b/src/spec/assets/img/usageMessageSpec.png differ

http://git-wip-us.apache.org/repos/asf/groovy/blob/0803166c/src/spec/doc/core-domain-specific-languages.adoc
----------------------------------------------------------------------
diff --git a/src/spec/doc/core-domain-specific-languages.adoc b/src/spec/doc/core-domain-specific-languages.adoc
index 001705f..3418c6c 100644
--- a/src/spec/doc/core-domain-specific-languages.adoc
+++ b/src/spec/doc/core-domain-specific-languages.adoc
@@ -1184,7 +1184,7 @@ properties are supported when specifying an allowed commandline option:
 | type           | the type of this option | `Class`
 | valueSeparator | the character that is the value separator | `char`<2>
 | defaultValue   | a default value | `String`
-| convert        | converts the incoming String to the required type | `Closure`<2>
+| convert        | converts the incoming String to the required type | `Closure`<1>
 |======================
 <1> More details later
 <2> Single character Strings are coerced to chars in special cases in Groovy
@@ -1324,7 +1324,7 @@ multiple arguments.
 Arguments on the commandline are by nature Strings (or arguably can be considered Booleans for flags) but can be
 converted to richer types automatically by supplying additional typing information. For the
 annotation-based argument definition style, these types are supplied using the field types for annotation
-properties or return types of annotated methods (are the setter argument type for setter methods).
+properties or return types of annotated methods (or the setter argument type for setter methods).
 For the dynamic method style of argument definition a special 'type' property is supported
 which allows you to specify a Class name.
 
@@ -1367,14 +1367,15 @@ include::{projectdir}/src/spec/test/builder/CliBuilderTest.groovy[tags=withConve
 
 ===== Options with multiple arguments
 
-Multiple arguments are also supported using an args value greater than 1. There is a special named parameter,
+Multiple arguments are also supported using an `args` value greater than 1. There is a special named parameter,
 `valueSeparator`, which can also be optionally used when processing multiple arguments. It allows some additional
 flexibility in the syntax supported when supplying such argument lists on the commandline. For example,
 supplying a value separator of ',' allows a comma-delimited list of values to be passed on the commandline.
 
 The `args` value is normally an integer. It can be optionally supplied as a String. There are two special
-String symbols: `+` and `*`. The `*` value means 0 or more. The `+` value means 1 or more. The `*` value is
-the same as using `+` and also setting the `optionalArg` value to true.
+String symbols: `&plus;` and `&#42;`.
+The `&#42;` value means 0 or more. The `&plus;` value means 1 or more.
+The `&#42;` value is the same as using `&plus;` and also setting the `optionalArg` value to true.
 
 Accessing the multiple arguments follows a special convention. Simply add an 's' to the normal property
 you would use to access the argument option and you will retrieve all the supplied arguments as a list.
@@ -1495,8 +1496,8 @@ Then, the following statements can be in a separate part of your code which is t
 ----
 def args = '--age 21'.split()
 def options = cli.parse(args)
-int age = options[age]
-assert age == 21
+int a = options[age]
+assert a == 21
 ----
 
 Finally, there is one additional convenience method offered by `CliBuilder` to even allow the
@@ -1516,7 +1517,8 @@ include::{projectdir}/src/spec/test/builder/CliBuilderTest.groovy[tags=withTypeC
 ===============================
 *NOTE* Advanced CLI features
 
-`CliBuilder` can be thought of as a Groovy friendly wrapper on top of (currently) Apache Commons CLI.
+`CliBuilder` can be thought of as a Groovy friendly wrapper on top of either
+https://github.com/remkop/picocli[picocli] or https://commons.apache.org/proper/commons-cli/[Apache Commons CLI].
 If there is a feature not provided by `CliBuilder` that you know is supported in the underlying
 library, the current `CliBuilder` implementation (and various Groovy language features) make it easy for you
 to call the underlying library methods directly. Doing so is a pragmatic way to leverage the Groovy-friendly
@@ -1525,6 +1527,8 @@ A word of caution however; future versions of `CliBuilder` could potentially use
 and in that event, some porting work may be required for your Groovy classes and/or scripts.
 ===============================
 
+====== Apache Commons CLI
+
 As an example, here is some code for making use of Apache Commons CLI's grouping mechanism:
 
 [source,groovy]
@@ -1544,6 +1548,103 @@ assert !cli.parse('-d -o'.split()) // <1>
 ----
 <1> The parse will fail since only one option from a group can be used at a time.
 
+====== Picocli
+Below are some features available in the picocli version of `CliBuilder`.
+
+*New property: errorWriter*
+
+When users of your application give invalid command line arguments,
+CliBuilder writes an error message and the usage help message to the `stderr` output stream.
+It doesn’t use the `stdout` stream to prevent the error message from being parsed when your program's
+output is used as input for another process.
+You can customize the destination by setting the `errorWriter` to a different value.
+
+On the other hand, `CliBuilder.usage()` prints the usage help message to the `stdout` stream.
+This way, when users request help (e.g. with a `--help` parameter),
+they can pipe the output to a utility like `less` or `grep`.
+
+You can specify different writers for testing.
+_Be aware that for backwards compatibility, setting the `writer` property to a different value
+will set *both* the `writer` and the `errorWriter` to the specified writer._
+
+*ANSI colors*
+
+The picocli version of CliBuilder renders the usage help message in ANSI colors on supported platforms automatically.
+If desired you can http://picocli.info/#_usage_help_with_styles_and_colors[customize] this.
+(An example follows below.)
+
+*New property: name*
+
+As before, you can set the synopsis of the usage help message with the `usage` property.
+You may be interested in a small improvement:
+if you only set the command `name`, a synopsis will be generated automatically,
+with repeating elements followed by `...` and optional elements surrounded with `[` and `]`.
+(An example follows below.)
+
+*New property: usageMessage*
+
+This property exposes a `UsageMessageSpec` object from the underlying picocli library,
+which gives fine-grained control over various sections of the usage help message. For example:
+
+[source,groovy]
+----
+def cli = new CliBuilder()
+cli.name = "myapp"
+cli.usageMessage.with {
+    headerHeading("@|bold,underline Header heading:|@%n")
+    header("Header 1", "Header 2")                     // before the synopsis
+    synopsisHeading("%n@|bold,underline Usage:|@ ")
+    descriptionHeading("%n@|bold,underline Description heading:|@%n")
+    description("Description 1", "Description 2")      // after the synopsis
+    optionListHeading("%n@|bold,underline Options heading:|@%n")
+    footerHeading("%n@|bold,underline Footer heading:|@%n")
+    footer("Footer 1", "Footer 2")
+}
+cli.a('option a description')
+cli.b('option b description')
+cli.c(args: '*', 'option c description')
+cli.usage()
+----
+Gives this output:
+
+image::assets/img/usageMessageSpec.png[]
+
+
+*New property: parser*
+
+The `parser` property gives access to the picocli `ParserSpec` object that can be used to customize the parser behavior.
+See the http://picocli.info/apidocs/picocli/CommandLine.Model.ParserSpec.html[documentation] for details.
+
+*Map options*
+
+Finally, if your application has options that are key-value pairs, you may be interested in picocli's support for maps. For example:
+
+[source,groovy]
+----
+import java.util.concurrent.TimeUnit
+import static java.util.concurrent.TimeUnit.DAYS
+import static java.util.concurrent.TimeUnit.HOURS
+
+def cli = new CliBuilder()
+cli.D(args: 2,   valueSeparator: '=', 'the old way')                          // <1>
+cli.X(type: Map, 'the new way')                                               // <2>
+cli.Z(type: Map, auxiliaryTypes: [TimeUnit, Integer].toArray(), 'typed map')  // <3>
+
+def options = cli.parse('-Da=b -Dc=d -Xx=y -Xi=j -ZDAYS=2 -ZHOURS=23'.split())// <4>
+assert options.Ds == ['a', 'b', 'c', 'd']                                     // <5>
+assert options.Xs == [ 'x':'y', 'i':'j' ]                                     // <6>
+assert options.Zs == [ (DAYS as TimeUnit):2, (HOURS as TimeUnit):23 ]         // <7>
+----
+<1> Previously, `key=value` pairs were split up into parts and added to a list
+<2> Picocli map support: simply specify `Map` as the type of the option
+<3> You can even specify the type of the map elements
+<4> To compare, let's specify two key-value pairs for each option
+<5> Previously, all key-value pairs end up in a list and it is up to the application to work with this list
+<6> Picocli returns the key-value pairs as a `Map`
+<7> Both keys and values of the map can be strongly typed
+
+
+
 ==== ObjectGraphBuilder
 
 `ObjectGraphBuilder` is a builder for an arbitrary graph of beans that

http://git-wip-us.apache.org/repos/asf/groovy/blob/0803166c/subprojects/groovy-cli-picocli/build.gradle
----------------------------------------------------------------------
diff --git a/subprojects/groovy-cli-picocli/build.gradle b/subprojects/groovy-cli-picocli/build.gradle
new file mode 100644
index 0000000..e41bea9
--- /dev/null
+++ b/subprojects/groovy-cli-picocli/build.gradle
@@ -0,0 +1,25 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+dependencies {
+    compile rootProject
+    compile "info.picocli:picocli:$picocliVersion"
+//    compile files ('C:/Users/remko/IdeaProjects/picocli/build/libs/picocli-3.0.0-beta-3-SNAPSHOT.jar')
+    testCompile project(':groovy-test')
+    testCompile project(':groovy-dateutil')
+}

http://git-wip-us.apache.org/repos/asf/groovy/blob/0803166c/subprojects/groovy-cli-picocli/src/main/groovy/groovy/cli/picocli/CliBuilder.groovy
----------------------------------------------------------------------
diff --git a/subprojects/groovy-cli-picocli/src/main/groovy/groovy/cli/picocli/CliBuilder.groovy b/subprojects/groovy-cli-picocli/src/main/groovy/groovy/cli/picocli/CliBuilder.groovy
new file mode 100644
index 0000000..352eb68
--- /dev/null
+++ b/subprojects/groovy-cli-picocli/src/main/groovy/groovy/cli/picocli/CliBuilder.groovy
@@ -0,0 +1,1028 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package groovy.cli.picocli
+
+import groovy.cli.CliBuilderException
+import groovy.cli.Option
+import groovy.cli.TypedOption
+import groovy.cli.Unparsed
+import groovy.transform.Undefined
+import org.codehaus.groovy.runtime.InvokerHelper
+import org.codehaus.groovy.runtime.MetaClassHelper
+import org.codehaus.groovy.runtime.StringGroovyMethods
+import picocli.CommandLine
+import picocli.CommandLine.ITypeConverter
+import picocli.CommandLine.Model.CommandSpec
+import picocli.CommandLine.Model.IGetter
+import picocli.CommandLine.Model.ISetter
+import picocli.CommandLine.Model.OptionSpec
+import picocli.CommandLine.Model.ParserSpec
+import picocli.CommandLine.Model.PositionalParamSpec
+import picocli.CommandLine.Model.UsageMessageSpec
+import picocli.CommandLine.ParseResult
+
+import java.lang.reflect.Field
+import java.lang.reflect.Method
+
+/**
+ * Provides a builder to assist the processing of command line arguments.
+ * Two styles are supported: dynamic api style (declarative method calls provide a mini DSL for describing options)
+ * and annotation style (annotations on an interface or class describe options).
+ * <p>
+ * <b>Dynamic api style</b>
+ * <p>
+ * Typical usage (emulate partial arg processing of unix command: ls -alt *.groovy):
+ * <pre>
+ * def cli = new CliBuilder(name:'ls')
+ * cli.a('display all files')
+ * cli.l('use a long listing format')
+ * cli.t('sort by modification time')
+ * def options = cli.parse(args)
+ * assert options // would be null (false) on failure
+ * assert options.arguments() == ['*.groovy']
+ * assert options.a && options.l && options.t
+ * </pre>
+ * The usage message for this example (obtained using <code>cli.usage()</code>) is shown below:
+ * <pre>
+ * Usage: ls [-alt]
+ *   -a     display all files
+ *   -l     use a long listing format
+ *   -t     sort by modification time
+ * </pre>
+ * An underlying parser that supports what is called argument 'bursting' is used
+ * by default. Bursting would convert '-alt' into '-a -l -t' provided no long
+ * option exists with value 'alt' and provided that none of 'a', 'l' or 't'
+ * takes an argument (in fact the last one is allowed to take an argument).
+ * The bursting behavior can be turned off by configuring the underlying parser.
+ * The simplest way to achieve this is by setting the posix property on the CliBuilder
+ * to false, i.e. include {@code posix: false} in the constructor call.
+ * <p>
+ * Another example (partial emulation of arg processing for 'ant' command line):
+ * <pre>
+ * def cli = new CliBuilder(usage:'ant [options] [targets]',
+ *                          header:'Options:')
+ * cli.help('print this message')
+ * cli.logfile(type:File, argName:'file', 'use given file for log')
+ * cli.D(type:Map, argName:'property=value', 'use value for given property')
+ * cli.lib(argName:'path', valueSeparator:',', args: '3',
+ *      'comma-separated list of 3 paths to search for jars and classes')
+ * def options = cli.parse(args)
+ * ...
+ * </pre>
+ * Usage message would be:
+ * <pre>
+ * Usage: ant [options] [targets]
+ * Options:
+ *   -D= &lt;property=value>   use value for given property
+ *       -help              print this message
+ *       -lib=&lt;path>,&lt;path>,&lt;path>
+ *                          comma-separated list of 3 paths to search for jars and
+ *                            classes
+ *       -logfile=&lt;file>    use given file for log
+ * </pre>
+ * And if called with the following arguments '-logfile foo -Dbar=baz -lib=/tmp,/usr/lib,~/libs target'
+ * then the following assertions would be true:
+ * <pre>
+ * assert options // would be null (false) on failure
+ * assert options.arguments() == ['target']
+ * assert options.D == ['bar': 'baz']
+ * assert options.libs == ['/tmp', '/usr/lib', '~/libs']
+ * assert options.lib == '/tmp'
+ * assert options.logfile == new File('foo')
+ * </pre>
+ * Note the use of some special notation. By adding 's' onto an option
+ * that may appear multiple times and has an argument or as in this case
+ * uses a valueSeparator to separate multiple argument values
+ * causes the list of associated argument values to be returned.
+ * <p>
+ * Another example showing long options (partial emulation of arg processing for 'curl' command line):
+ * <pre>
+ * def cli = new CliBuilder(usage:'curl [options] &lt;url&gt;')
+ * cli._(longOpt:'basic', 'Use HTTP Basic Authentication')
+ * cli.d(longOpt:'data', args:1, argName:'data', 'HTTP POST data')
+ * cli.G(longOpt:'get', 'Send the -d data with a HTTP GET')
+ * cli.q('If used as the first parameter disables .curlrc')
+ * cli._(longOpt:'url', args:1, argName:'URL', 'Set URL to work with')
+ * </pre>
+ * Which has the following usage message:
+ * <pre>
+ * Usage: curl [options] &lt;url>
+ *       -basic, --basic         Use HTTP Basic Authentication
+ *   -d, -data, --data=&lt;data>    HTTP POST data
+ *   -G, -get, --get             Send the -d data with a HTTP GET
+ *   -q                          If used as the first parameter disables .curlrc
+ *       -url, --url=&lt;URL>       Set URL to work with
+ * </pre>
+ * This example shows a common convention. When mixing short and long names, the
+ * short names are often one character in size. One character options with
+ * arguments don't require a space between the option and the argument, e.g.
+ * <code>-Ddebug=true</code>. The example also shows
+ * the use of '_' when no short option is applicable.
+ * <p>
+ * Also note that '_' was used multiple times. This is supported but if
+ * any other shortOpt or any longOpt is repeated, then the underlying library throws an exception.
+ * <p>
+ * Short option names may not contain a hyphen. If a long option name contains a hyphen, e.g. '--max-wait' then you can either
+ * use the long hand method call <code>options.hasOption('max-wait')</code> or surround
+ * the option name in quotes, e.g. <code>options.'max-wait'</code>.
+ * <p>
+ * Although CliBuilder on the whole hides away the underlying library used
+ * for processing the arguments, it does provide some hooks which let you
+ * make use of the underlying library directly should the need arise. For
+ * example, the last two lines of the 'curl' example above could be replaced
+ * with the following:
+ * <pre>
+ * import picocli.CommandLine.Model.*
+ * ... as before ...
+ * cli << OptionSpec.builder('-q').
+ *                      description('If used as the first parameter disables .curlrc').build()
+ * cli << OptionSpec.builder('--url').type(URL.class).paramLabel('&lt;URL>').
+ *                      description('Set URL to work with').build()
+ * ...
+ * </pre>
+ *
+ * <p>
+ * <b>Supported Option Properties</b>:
+ * <table border="1" cellspacing="0">
+ *   <tr>
+ *     <th>Property</th>
+ *     <th>Type</th>
+ *     <th>Picocli equivalent</th>
+ *     <th>Description</th>
+ *   </tr>
+ *   <tr>
+ *     <th><code>argName</code></th>
+ *     <td>String</td>
+ *     <td><code>names</code></td>
+ *     <td>Short name for the option, will be prefixed with a single hyphen.</td>
+ *   </tr>
+ *   <tr>
+ *     <th><code>longOpt</code></th>
+ *     <td>String</td>
+ *     <td><code>names</code></td>
+ *     <td>Long name for the option, which may be prefixed with either a single hypen or two hyphens.
+ *       An option must have either a long name or a short name (or both).</td>
+ *   </tr>
+ *   <tr>
+ *     <th><code>args</code></th>
+ *     <td>int&nbsp;or&nbsp;String</td>
+ *     <td><code>arity</code></td>
+ *     <td><code>args</code> indicates the number of parameters for this option.
+ *       A String value of '+' indicates at least one up to any number of parameters.
+ *       The minimum number of parameters depends on the type (booleans require no parameters)
+ *       and the <code>optionalArg</code> setting.
+ *       </td>
+ *   </tr>
+ *   <tr>
+ *     <th><code>optionalArg</code></th>
+ *     <td>boolean</td>
+ *     <td><code>arity</code></td>
+ *     <td>If <code>optionalArg=true</code>, then <code>args=3</code>
+ *       is the equivalent of <code>arity="0..3"</code> in picocli.
+ *       When <code>optionalArg=true</code>, <code>args='+'</code>
+ *       is equivalent to <code>arity="0..*"</code>.
+ *       </td>
+ *   </tr>
+ *   <tr>
+ *     <th><code>required</code></th>
+ *     <td>boolean</td>
+ *     <td><code>required</code></td>
+ *     <td>If <code>true</code>, this option must be specified on the command line, or an exception is thrown.
+ *       </td>
+ *   </tr>
+ *   <tr>
+ *     <th><code>type</code></th>
+ *     <td>Class</td>
+ *     <td><code>type</code></td>
+ *     <td>Option parameters are converted to this type. The underlying library has built-in converters for
+ *     <a href="http://picocli.info/#_built_in_types">many types</a>.
+ *       A custom converter can be specified with the <code>convert</code> property.
+ *       </td>
+ *   </tr>
+ *   <tr>
+ *     <th><code>convert</code></th>
+ *     <td>Closure</td>
+ *     <td><code>converter</code></td>
+ *     <td>A closure that takes a single String parameter and returns an object converted to the <code>type</code> of this option.
+ *       The picocli equivalent is the <code><a href="http://picocli.info/#_custom_type_converters">ITypeConverter</a></code> interface.
+ *       </td>
+ *   </tr>
+ *   <tr>
+ *     <th><code>valueSeparator</code></th>
+ *     <td>char</td>
+ *     <td><code>splitRegex</code></td>
+ *     <td>The character used to split a single command line argument into parts.
+ *       </td>
+ *   </tr>
+ *   <tr>
+ *     <th><code>defaultValue</code></th>
+ *     <td>String</td>
+ *     <td><code>defaultValue</code></td>
+ *     <td>The value the option should have if it did not appear on the command line.
+ *       The specified String value will be split into parts with the <code>valueSeparator</code> and
+ *       converted to the option <code>type</code> before it is set.
+ *       </td>
+ *   </tr>
+ * </table>
+ * See {@link CliBuilderTest} for further examples.
+ * <p>
+ * <b>@-files</b>
+ * <p>
+ * CliBuilder also supports Argument File processing. If an argument starts with
+ * an '@' character followed by a filename, then the contents of the file with name
+ * filename are placed into the command line. The feature can be turned off by
+ * setting expandArgumentFiles to false. If turned on, you can still pass a real
+ * parameter with an initial '@' character by escaping it with an additional '@'
+ * symbol, e.g. '@@foo' will become '@foo' and not be subject to expansion. As an
+ * example, if the file temp.args contains the content:
+ * <pre>
+ * -arg1
+ * paramA
+ * paramB paramC
+ * </pre>
+ * Then calling the command line with:
+ * <pre>
+ * someCommand @temp.args -arg2 paramD
+ * </pre>
+ * Is the same as calling this:
+ * <pre>
+ * someCommand -arg1 paramA paramB paramC -arg2 paramD
+ * </pre>
+ * This feature is particularly useful on operating systems which place limitations
+ * on the size of the command line (e.g. Windows). The feature is similar to
+ * the 'Command Line Argument File' processing supported by javadoc and javac.
+ * Consult the corresponding documentation for those tools if you wish to see further examples.
+ * <p>
+ * <b>Annotation style with an interface</b>
+ * <p>
+ * With this style an interface is defined containing an annotated method for each option.
+ * It might look like this (following roughly the earlier 'ls' example):
+ * <pre>
+ * import groovy.cli.Option
+ * import groovy.cli.Unparsed
+ *
+ * interface OptionInterface {
+ *     @{@link groovy.cli.Option}(shortName='a', description='display all files') boolean all()
+ *     @{@link groovy.cli.Option}(shortName='l', description='use a long listing format') boolean longFormat()
+ *     @{@link groovy.cli.Option}(shortName='t', description='sort by modification time') boolean time()
+ *     @{@link groovy.cli.Unparsed} List remaining()
+ * }
+ * </pre>
+ * Then this description is supplied to CliBuilder during parsing, e.g.:
+ * <pre>
+ * def args = '-alt *.groovy'.split() // normally from commandline itself
+ * def cli = new CliBuilder(usage:'ls')
+ * def options = cli.parseFromSpec(OptionInterface, args)
+ * assert options.remaining() == ['*.groovy']
+ * assert options.all() && options.longFormat() && options.time()
+ * </pre>
+ * <p>
+ * <b>Annotation style with a class</b>
+ * <p>
+ * With this style a user-supplied instance is used. Annotations on that instance's class
+ * members (properties and setter methods) indicate how to set options and provide the option details
+ * using annotation attributes.
+ * It might look like this (again using the earlier 'ls' example):
+ * <pre>
+ * import groovy.cli.Option
+ * import groovy.cli.Unparsed
+ *
+ * class OptionClass {
+ *     @{@link groovy.cli.Option}(shortName='a', description='display all files') boolean all
+ *     @{@link groovy.cli.Option}(shortName='l', description='use a long listing format') boolean longFormat
+ *     @{@link groovy.cli.Option}(shortName='t', description='sort by modification time') boolean time
+ *     @{@link groovy.cli.Unparsed} List remaining
+ * }
+ * </pre>
+ * Then this description is supplied to CliBuilder during parsing, e.g.:
+ * <pre>
+ * def args = '-alt *.groovy'.split() // normally from commandline itself
+ * def cli = new CliBuilder(usage:'ls')
+ * def options = new OptionClass()
+ * cli.parseFromInstance(options, args)
+ * assert options.remaining == ['*.groovy']
+ * assert options.all && options.longFormat && options.time
+ * </pre>
+ */
+class CliBuilder {
+    /**
+     * The command synopsis displayed as the first line in the usage help message, e.g., when <code>cli.usage()</code> is called.
+     * When not set, a default synopsis is generated that shows the supported options and parameters.
+     */
+    String usage = 'groovy'
+
+    /**
+     * This property allows customizing the program name displayed in the synopsis when <code>cli.usage()</code> is called.
+     * Ignored if the {@link #usage} property is set.
+     * @since 2.5
+     */
+    String name = 'groovy'
+
+    /**
+     * To disallow clustered POSIX short options, set this to false.
+     */
+    Boolean posix = true
+
+    /**
+     * Whether arguments of the form '{@code @}<i>filename</i>' will be expanded into the arguments contained within the file named <i>filename</i> (default true).
+     */
+    boolean expandArgumentFiles = true
+
+    /**
+     * Configures what the parser should do when arguments not recognized
+     * as options are encountered: when <code>true</code> (the default), the
+     * remaining arguments are all treated as positional parameters.
+     * When <code>false</code>, the parser will continue to look for options, and
+     * only the unrecognized arguments are treated as positional parameters.
+     */
+    boolean stopAtNonOption = true
+
+    /**
+     * For backwards compatibility with Apache Commons CLI, set this property to
+     * <code>true</code> if the parser should recognize long options with both
+     * a single hyphen and a double hyphen prefix. The default is <code>false</code>,
+     * so only long options with a double hypen prefix (<code>--option</code>) are recognized.
+     */
+    boolean acceptLongOptionsWithSingleHyphen = false
+
+    /**
+     * The PrintWriter to write the {@linkplain #usage} help message to
+     * when <code>cli.usage()</code> is called.
+     * Defaults to stdout but you can provide your own PrintWriter if desired.
+     */
+    PrintWriter writer = new PrintWriter(System.out)
+
+    /**
+     * The PrintWriter to write to when invalid user input was provided to
+     * the {@link #parse(java.lang.String[])} method.
+     * Defaults to stderr but you can provide your own PrintWriter if desired.
+     * @since 2.5
+     */
+    PrintWriter errorWriter = new PrintWriter(System.err)
+
+    /**
+     * Optional additional message for usage; displayed after the usage summary
+     * but before the options are displayed.
+     */
+    String header = null
+
+    /**
+     * Optional additional message for usage; displayed after the options.
+     */
+    String footer = null
+
+    /**
+     * Allows customisation of the usage message width.
+     */
+    int width = UsageMessageSpec.DEFAULT_USAGE_WIDTH
+
+    /**
+     * Not normally accessed directly but allows fine-grained control over the
+     * parser behaviour via the API of the underlying library if needed.
+     * @since 2.5
+     */
+    // Implementation note: this object is separate from the CommandSpec.
+    // The values collected here are copied into the ParserSpec of the command.
+    final ParserSpec parser = new ParserSpec()
+            .stopAtPositional(true)
+            .unmatchedOptionsArePositionalParams(true)
+            .aritySatisfiedByAttachedOptionParam(true)
+            .limitSplit(true)
+            .overwrittenOptionsAllowed(true)
+            .toggleBooleanFlags(false)
+
+    /**
+     * Not normally accessed directly but allows fine-grained control over the
+     * usage help message via the API of the underlying library if needed.
+     * @since 2.5
+     */
+    // Implementation note: this object is separate from the CommandSpec.
+    // The values collected here are copied into the UsageMessageSpec of the command.
+    final UsageMessageSpec usageMessage = new UsageMessageSpec()
+
+    Map<String, TypedOption> savedTypeOptions = new HashMap<String, TypedOption>()
+
+    // CommandSpec is the entry point into the picocli object model for a command.
+    // It gives access to a ParserSpec to customize the parser behaviour and
+    // a UsageMessageSpec to customize the usage help message.
+    // Add OptionSpec and PositionalParamSpec objects to this object to define
+    // the options and positional parameters this command recognizes.
+    //
+    // This field is private for now.
+    // It is initialized to an empty spec so options and positional parameter specs
+    // can be added dynamically via the programmatic API.
+    // When a command spec is defined via annotations, the existing instance is
+    // replaced with a new one. This allows the outer CliBuilder instance can be reused.
+    private CommandSpec commandSpec = CommandSpec.create()
+
+    void setUsage(String usage) {
+        this.usage = usage
+        usageMessage.customSynopsis(usage)
+    }
+
+    void setFooter(String footer) {
+        this.footer = footer
+        usageMessage.footer(footer)
+    }
+
+    void setHeader(String header) {
+        this.header = header
+        // "header" is displayed after the synopsis in previous CliBuilder versions.
+        // The picocli equivalent is the "description".
+        usageMessage.description(header)
+    }
+
+    void setWidth(int width) {
+        this.width = width
+        usageMessage.width(width)
+    }
+
+    void setExpandArgumentFiles(boolean expand) {
+        this.expandArgumentFiles = expand
+        parser.expandAtFiles(expand)
+    }
+
+    void setPosix(boolean posix) {
+        this.posix = posix
+        parser.posixClusteredShortOptionsAllowed(posix)
+    }
+
+    void setStopAtNonOption(boolean stopAtNonOption) {
+        this.stopAtNonOption = stopAtNonOption
+        parser.stopAtPositional(stopAtNonOption)
+    }
+
+    /**
+     * For backwards compatibility reasons, if a custom {@code writer} is set, this sets
+     * both the {@link #writer} and the {@link #errorWriter} to the specified writer.
+     * @param writer the writer to initialize both the {@code writer} and the {@code errorWriter} to
+     */
+    void setWriter(PrintWriter writer) {
+        this.writer = writer
+        this.errorWriter = writer
+    }
+
+    public <T> TypedOption<T> option(Map args, Class<T> type, String description) {
+        def name = args.opt ?: '_'
+        args.type = type
+        args.remove('opt')
+        "$name"(args, description)
+    }
+
+    /**
+     * Internal method: Detect option specification method calls.
+     */
+    def invokeMethod(String name, Object args) {
+        if (args instanceof Object[]) {
+            if (args.size() == 1 && (args[0] instanceof String || args[0] instanceof GString)) {
+                def option = option(name, [:], args[0]) // args[0] is description
+                commandSpec.addOption(option)
+                return create(option, null, null, null)
+            }
+            if (args.size() == 1 && args[0] instanceof OptionSpec && name == 'leftShift') {
+                OptionSpec option = args[0] as OptionSpec
+                commandSpec.addOption(option)
+                return create(option, null, null, null)
+            }
+            if (args.size() == 2 && args[0] instanceof Map) {
+                Map m = args[0] as Map
+                if (m.type && !(m.type instanceof Class)) {
+                    throw new CliBuilderException("'type' must be a Class")
+                }
+                def option = option(name, m, args[1])
+                commandSpec.addOption(option)
+                return create(option, m.type, option.defaultValue(), option.converters())
+            }
+        }
+        return InvokerHelper.getMetaClass(this).invokeMethod(this, name, args)
+    }
+
+    private TypedOption create(OptionSpec o, Class theType, defaultValue, convert) {
+        String opt = o.names().sort { a, b -> a.length() - b.length() }.first()
+        opt = opt?.length() == 2 ? opt.substring(1) : null
+
+        String longOpt = o.names().sort { a, b -> b.length() - a.length() }.first()
+        longOpt = longOpt?.startsWith("--") ? longOpt.substring(2) : null
+
+        Map<String, Object> result = new TypedOption<Object>()
+        if (opt != null) result.put("opt", opt)
+        result.put("longOpt", longOpt)
+        result.put("cliOption", o)
+        if (defaultValue) {
+            result.put("defaultValue", defaultValue)
+        }
+        if (convert) {
+            if (theType) {
+                throw new CliBuilderException("You can't specify 'type' when using 'convert'")
+            }
+            result.put("convert", convert)
+            result.put("type", convert instanceof Class ? convert : convert.getClass())
+        } else {
+            result.put("type", theType)
+        }
+        savedTypeOptions[longOpt ?: opt] = result
+        result
+    }
+
+    /**
+     * Make options accessible from command line args with parser.
+     * Returns null on bad command lines after displaying usage message.
+     */
+    OptionAccessor parse(args) {
+        CommandLine commandLine = createCommandLine()
+        try {
+            def accessor = new OptionAccessor(commandLine.parseArgs(args as String[]))
+            accessor.savedTypeOptions = savedTypeOptions
+            return accessor
+        } catch (CommandLine.ParameterException pe) {
+            errorWriter.println("error: " + pe.message)
+            printUsage(pe.commandLine, errorWriter)
+            return null
+        }
+    }
+
+    private CommandLine createCommandLine() {
+        commandSpec.parser(parser)
+        commandSpec.name(name).usageMessage(usageMessage)
+        if (commandSpec.positionalParameters().empty) {
+            commandSpec.addPositional(PositionalParamSpec.builder().type(String[]).arity("*").paramLabel("P").hidden(true).build())
+        }
+        return new CommandLine(commandSpec)
+    }
+
+    /**
+     * Prints the usage message with the specified {@link #header header}, {@link #footer footer} and {@link #width width}
+     * to the specified {@link #writer writer} (default: System.out).
+     */
+    void usage() {
+        printUsage(commandSpec.commandLine() ?: createCommandLine(), writer)
+    }
+
+    private void printUsage(CommandLine commandLine, PrintWriter pw) {
+        commandLine.usage(pw)
+        pw.flush()
+    }
+
+    /**
+     * Given an interface containing members with annotations, derive
+     * the options specification.
+     *
+     * @param optionsClass
+     * @param args
+     * @return an instance containing the processed options
+     */
+    public <T> T parseFromSpec(Class<T> optionsClass, String[] args) {
+        def cliOptions = [:]
+        commandSpec = CommandSpec.create()
+        addOptionsFromAnnotations(optionsClass, cliOptions, true)
+        addPositionalsFromAnnotations(optionsClass, cliOptions, true)
+        parse(args)
+        cliOptions as T
+    }
+
+    /**
+     * Given an instance containing members with annotations, derive
+     * the options specification.
+     *
+     * @param optionInstance
+     * @param args
+     * @return the options instance populated with the processed options
+     */
+    public <T> T parseFromInstance(T optionInstance, args) {
+        commandSpec = CommandSpec.create()
+        addOptionsFromAnnotations(optionInstance.getClass(), optionInstance, false)
+        addPositionalsFromAnnotations(optionInstance.getClass(), optionInstance, false)
+        def optionAccessor = parse(args)
+
+        // initialize the boolean properties that were not matched
+        if (optionAccessor) {
+            optionAccessor.parseResult.commandSpec().options().each { option ->
+                if (!optionAccessor.parseResult.hasMatchedOption(option)) {
+                    boolean isFlag = option.arity().max == 0 && option.type().simpleName.toLowerCase() == 'boolean'
+                    if (isFlag) { option.value = false } // else default has already been applied
+                }
+            }
+        }
+        optionInstance
+    }
+
+    private void addOptionsFromAnnotations(Class optionClass, Object target, boolean isCoercedMap) {
+        optionClass.methods.findAll{ it.getAnnotation(Option) }.each { Method m ->
+            Option annotation = m.getAnnotation(Option)
+            ArgSpecAttributes attributes = extractAttributesFromMethod(m, isCoercedMap, target)
+            commandSpec.addOption(createOptionSpec(annotation, attributes, target))
+        }
+        def optionFields = optionClass.declaredFields.findAll { it.getAnnotation(Option) }
+        if (optionClass.isInterface() && !optionFields.isEmpty()) {
+            throw new CliBuilderException("@Option only allowed on methods in interface " + optionClass.simpleName)
+        }
+        optionFields.each { Field f ->
+            Option annotation = f.getAnnotation(Option)
+            ArgSpecAttributes attributes = extractAttributesFromField(f, target)
+            commandSpec.addOption(createOptionSpec(annotation, attributes, target))
+        }
+    }
+
+    private void addPositionalsFromAnnotations(Class optionClass, Object target, boolean isCoercedMap) {
+        optionClass.methods.findAll{ it.getAnnotation(Unparsed) }.each { Method m ->
+            Unparsed annotation = m.getAnnotation(Unparsed)
+            ArgSpecAttributes attributes = extractAttributesFromMethod(m, isCoercedMap, target)
+            commandSpec.addPositional(createPositionalParamSpec(annotation, attributes, target))
+        }
+        def optionFields = optionClass.declaredFields.findAll { it.getAnnotation(Unparsed) }
+        if (optionClass.isInterface() && !optionFields.isEmpty()) {
+            throw new CliBuilderException("@Unparsed only allowed on methods in interface " + optionClass.simpleName)
+        }
+        optionFields.each { Field f ->
+            Unparsed annotation = f.getAnnotation(Unparsed)
+            ArgSpecAttributes attributes = extractAttributesFromField(f, target)
+            commandSpec.addPositional(createPositionalParamSpec(annotation, attributes, target))
+        }
+    }
+
+    private static class ArgSpecAttributes {
+        Class type
+        Class[] auxiliaryTypes
+        String label
+        IGetter getter
+        ISetter setter
+        Object initialValue
+        boolean hasInitialValue
+    }
+
+    private ArgSpecAttributes extractAttributesFromMethod(Method m, boolean isCoercedMap, target) {
+        Class type = isCoercedMap ? m.returnType : (m.parameterTypes.size() > 0 ? m.parameterTypes[0] : m.returnType)
+        type = type && type == Void.TYPE ? null : type
+
+        Class[] auxTypes = null // TODO extract generic types like List<Integer> or Map<Integer,Double>
+
+        // If the method is a real setter, we can't invoke it to get its value,
+        // so instead we need to keep track of its current value ourselves.
+        // Additionally, implementation classes may annotate _getter_ methods with @Option;
+        // if the getter returns a Collection or Map, picocli will add parsed values to it.
+        def currentValue = initialValue(type, m, target, isCoercedMap)
+        def getter = {
+            currentValue
+        }
+        def setter = {
+            def old = currentValue
+            currentValue = it
+            if (!isCoercedMap && m.parameterTypes.size() > 0) {
+                m.invoke(target, [currentValue].toArray())
+            }
+            return old
+        }
+        if (isCoercedMap) {
+            target[m.name] = getter
+        }
+        def label = m.name.startsWith("set") || m.name.startsWith("get") ? MetaClassHelper.convertPropertyName(m.name.substring(3)) : m.name
+        new ArgSpecAttributes(type: type, auxiliaryTypes: auxTypes, label: label, getter: getter, setter: setter, initialValue: currentValue, hasInitialValue: isCoercedMap)
+    }
+
+    private Object initialValue(Class<?> cls, Method m, Object target, boolean isCoercedMap) {
+        if (m.parameterTypes.size() == 0 && m.returnType != Void.TYPE) { // annotated getter
+            if (!isCoercedMap) {
+                return m.invoke(target)
+            }
+            if (cls.primitive) {
+                if (cls.simpleName.toLowerCase() == 'boolean') {
+                    return false
+                }
+                return 0
+            }
+            return target[m.name]
+        }
+        // annotated setter
+        if (List.class.isAssignableFrom(cls)) { // TODO support other Collections in future
+            return new ArrayList()
+        }
+        if (Map.class.isAssignableFrom(cls)) {
+            return new LinkedHashMap()
+        }
+        null
+    }
+
+    private ArgSpecAttributes extractAttributesFromField(Field f, target) {
+        def getter = {
+            f.accessible = true
+            f.get(target);
+        }
+        def setter = { newValue ->
+            f.accessible = true
+            def oldValue = f.get(target)
+            f.set(target, newValue)
+            oldValue
+        }
+        Class[] auxTypes = null // TODO extract generic types like List<Integer> or Map<Integer,Double>
+        new ArgSpecAttributes(type: f.type, auxiliaryTypes: auxTypes, label: f.name, getter: getter, setter: setter, initialValue: getter.call(), hasInitialValue: true)
+    }
+
+    private PositionalParamSpec createPositionalParamSpec(Unparsed unparsed, ArgSpecAttributes attr, Object target) {
+        PositionalParamSpec.Builder builder = PositionalParamSpec.builder();
+
+        CommandLine.Range arity = CommandLine.Range.valueOf("0..*")
+        if (attr.type == Object) { attr.type = String[] }
+        if (attr.type)           { builder.type(attr.type) } // cannot set type to null
+        if (attr.auxiliaryTypes) { builder.auxiliaryTypes(attr.auxiliaryTypes) } // cannot set aux types to null
+        builder.arity(arity)
+        builder.paramLabel("<$attr.label>")
+        builder.getter(attr.getter)
+        builder.setter(attr.setter)
+        builder.hasInitialValue(attr.hasInitialValue)
+        if (arity.max == 0 && attr.type.simpleName.toLowerCase() == 'boolean' && !attr.initialValue) {
+            attr.initialValue = false
+        }
+        try {
+            builder.initialValue(attr.initialValue)
+        } catch (Exception ex) {
+            throw new CliBuilderException("Could not get initial value of positional parameters: " + ex, ex)
+        }
+        builder.build()
+    }
+
+    private OptionSpec createOptionSpec(Option annotation, ArgSpecAttributes attr, Object target) {
+        Map names = calculateNames(annotation.longName(), annotation.shortName(), attr.label)
+        String arityString = extractArity(attr.type, annotation.optionalArg(), annotation.numberOfArguments(), annotation.numberOfArgumentsString(), names)
+        CommandLine.Range arity = CommandLine.Range.valueOf(arityString)
+        if (attr.type == Object && arity.max == 0) { attr.type = boolean }
+        OptionSpec.Builder builder = OptionSpec.builder(hyphenate(names))
+        if (attr.type)           { builder.type(attr.type) } // cannot set type to null
+        if (attr.auxiliaryTypes) { builder.auxiliaryTypes(attr.auxiliaryTypes) } // cannot set aux types to null
+        builder.arity(arity)
+        builder.description(annotation.description())
+        builder.splitRegex(annotation.valueSeparator())
+        if (annotation.defaultValue()) { builder.defaultValue(annotation.defaultValue()) } // don't default picocli model to empty string
+        builder.paramLabel("<$attr.label>")
+        if (annotation.convert() != Undefined.CLASS) {
+            if (annotation.convert() instanceof Class) {
+                builder.converters(annotation.convert().newInstance(target, target) as ITypeConverter)
+            }
+        }
+        builder.getter(attr.getter)
+        builder.setter(attr.setter)
+        builder.hasInitialValue(attr.hasInitialValue)
+        if (arity.max == 0 && attr.type.simpleName.toLowerCase() == 'boolean' && !attr.initialValue) {
+            attr.initialValue = false
+        }
+        try {
+            builder.initialValue(attr.initialValue)
+        } catch (Exception ex) {
+            throw new CliBuilderException("Could not get initial value of option " + names + ": " + ex, ex)
+        }
+        builder.build()
+    }
+
+    private String[] hyphenate(Map<String, String> names) {
+        def both = acceptLongOptionsWithSingleHyphen
+        names.values().findAll { it && it != "_" }.collect { it.length() == 1 ? "-$it" : (both ? ["-$it", "--$it"] : ["--$it"]) }.flatten().toArray()
+    }
+
+    private String extractArity(Class<?> type, boolean optionalArg, int numberOfArguments, String numberOfArgumentsString, Map names) {
+        if (optionalArg && (!type || !isMultiValue(type))) {
+            throw new CliBuilderException("Attempted to set optional argument for single-value type on flag '${names.long ?: names.short}'")
+        }
+        if (numberOfArguments != 1 && numberOfArgumentsString) {
+            throw new CliBuilderException("You can't specify both 'numberOfArguments' and 'numberOfArgumentsString' on flag '${names.long ?: names.short}'")
+        }
+        def isFlag = type.simpleName.toLowerCase() == 'boolean' ||
+                     (type.simpleName.toLowerCase() == 'object' && (numberOfArguments == 0 || numberOfArgumentsString == "0"))
+        String arity = "0"
+        if (numberOfArgumentsString) {
+            String max = numberOfArgumentsString.replace('+', '*')
+            arity = optionalArg ? "0..$max" : "1..$max"
+        } else {
+            if (!isFlag) {
+                arity = optionalArg ? "0..$numberOfArguments" : "1..$numberOfArguments"
+            }
+        }
+        if (arity == "0" && !(isFlag || type.name == 'java.lang.Object')) {
+            throw new CliBuilderException("Flag '${names.long ?: names.short}' must be Boolean or Object")
+        }
+        arity
+    }
+    private static boolean isMultiValue(Class<?> cls) {
+        cls.isArray() || Collection.class.isAssignableFrom(cls) || Map.class.isAssignableFrom(cls)
+    }
+
+    private Map calculateNames(String longName, String shortName, String label) {
+        boolean useShort = longName == '_'
+        if (longName == '_') longName = ""
+        def result = longName ?: label
+        [long: useShort ? "" : result, short: (useShort && !shortName) ? result : shortName]
+    }
+
+    // implementation details -------------------------------------
+    /**
+     * Internal method: How to create an OptionSpec from the specification.
+     */
+    OptionSpec option(shortname, Map details, description) {
+        OptionSpec.Builder builder
+        if (shortname == '_') {
+            builder = OptionSpec.builder("--$details.longOpt").description(description)
+            if (acceptLongOptionsWithSingleHyphen) {
+                builder.names("-$details.longOpt", "--$details.longOpt")
+            }
+            details.remove('longOpt')
+        } else {
+            builder = OptionSpec.builder("-$shortname").description(description)
+        }
+        commons2picocli(shortname, details).each { key, value ->
+            if (builder.hasProperty(key)) {
+                builder[key] = value
+            } else {
+                builder.invokeMethod(key, value)
+            }
+        }
+        if (!builder.type() && !builder.arity() && builder.converters()?.length > 0) {
+            builder.arity("1").type(details.convert ? Object : String[])
+        }
+        return builder.build()
+    }
+
+    /** Commons-cli constant that specifies the number of argument values is infinite */
+    private static final int COMMONS_CLI_UNLIMITED_VALUES = -2;
+
+    // - argName:        String
+    // - longOpt:        String
+    // - args:           int or String
+    // - optionalArg:    boolean
+    // - required:       boolean
+    // - type:           Class
+    // - valueSeparator: char
+    // - convert:        Closure
+    // - defaultValue:   String
+    private Map commons2picocli(shortname, Map m) {
+        if (m.args && m.optionalArg) {
+            m.arity = "0..${m.args}"
+            m.remove('args')
+            m.remove('optionalArg')
+        }
+        if (!m.defaultValue) {
+            m.remove('defaultValue') // don't default the picocli model to empty string
+        }
+        def result = m.collectMany { k, v ->
+            if (k == 'args' && v == '+') {
+                [[arity: '1..*']]
+            } else if (k == 'args' && v == 0) {
+                [[arity: '0']]
+            } else if (k == 'args') {
+                v == COMMONS_CLI_UNLIMITED_VALUES ? [[arity: "*"]] : [[arity: "$v"]]
+            } else if (k == 'optionalArg') {
+                v ? [[arity: '0..1']] : [[arity: '1']]
+            } else if (k == 'argName') {
+                [[paramLabel: "<$v>"]]
+            } else if (k == 'longOpt') {
+                acceptLongOptionsWithSingleHyphen ?
+                        [[names: ["-$shortname", "-$v", "--$v"] as String[] ]] :
+                        [[names: ["-$shortname",        "--$v"] as String[] ]]
+            } else if (k == 'valueSeparator') {
+                [[splitRegex: "$v"]]
+            } else if (k == 'convert') {
+                [[converters: [v] as ITypeConverter[] ]]
+            } else {
+                [[(k): v]]
+            }
+        }.sum() as Map
+        result
+    }
+}
+
+class OptionAccessor {
+    ParseResult parseResult
+    Map<String, TypedOption> savedTypeOptions
+
+    OptionAccessor(ParseResult parseResult) {
+        this.parseResult = parseResult
+    }
+
+    boolean hasOption(TypedOption typedOption) {
+        parseResult.hasMatchedOption(typedOption.longOpt ?: typedOption.opt as String)
+    }
+
+    public <T> T defaultValue(String name) {
+        Class<T> type = savedTypeOptions[name]?.type
+        String value = savedTypeOptions[name]?.defaultValue() ? savedTypeOptions[name].defaultValue() : null
+        return (T) value ? getTypedValue(type, name, value) : null
+    }
+
+    public <T> T getOptionValue(TypedOption<T> typedOption) {
+        getOptionValue(typedOption, null)
+    }
+
+    public <T> T getOptionValue(TypedOption<T> typedOption, T defaultValue) {
+        String optionName = (String) typedOption.longOpt ?: typedOption.opt
+        if (parseResult.hasMatchedOption(optionName)) {
+            return parseResult.matchedOptionValue(optionName, defaultValue)
+        } else {
+            OptionSpec option = parseResult.commandSpec().findOption(optionName)
+            return option ? option.value : defaultValue
+        }
+    }
+
+    public <T> T getAt(TypedOption<T> typedOption) {
+        getAt(typedOption, null)
+    }
+
+    public <T> T getAt(TypedOption<T> typedOption, T defaultValue) {
+        getOptionValue(typedOption, defaultValue)
+    }
+
+    private <T> T getTypedValue(Class<T> type, String optionName, String optionValue) {
+        if (savedTypeOptions[optionName]?.cliOption?.arity?.min == 0) { // TODO is this not a bug?
+            return (T) parseResult.hasMatchedOption(optionName) // TODO should defaultValue not simply convert the type regardless of the matched value?
+        }
+        def convert = savedTypeOptions[optionName]?.convert
+        return getValue(type, optionValue, convert)
+    }
+
+    private <T> T getValue(Class<T> type, String optionValue, Closure convert) {
+        if (!type) {
+            return (T) optionValue
+        }
+        if (Closure.isAssignableFrom(type) && convert) {
+            return (T) convert(optionValue)
+        }
+        if (type == Boolean || type == Boolean.TYPE) {
+            return type.cast(Boolean.parseBoolean(optionValue))
+        }
+        StringGroovyMethods.asType(optionValue, (Class<T>) type)
+    }
+
+    Properties getOptionProperties(String name) {
+        if (!parseResult.hasMatchedOption(name)) {
+            return null
+        }
+        List<String> keyValues = parseResult.matchedOption(name).stringValues()
+        Properties result = new Properties()
+        keyValues.toSpreadMap().each { k, v -> result.setProperty(k, v) }
+        result
+    }
+
+    def invokeMethod(String name, Object args) {
+        // TODO we could just declare normal methods to map commons-cli CommandLine methods to picocli ParseResult methods
+        if (name == 'hasOption')      { name = 'hasMatchedOption';   args = [args[0]      ].toArray() }
+        if (name == 'getOptionValue') { name = 'matchedOptionValue'; args = [args[0], null].toArray() }
+        return InvokerHelper.getMetaClass(parseResult).invokeMethod(parseResult, name, args)
+    }
+
+    def getProperty(String name) {
+        if (name == 'parseResult') { return parseResult }
+        if (parseResult.hasMatchedOption(name)) {
+            def result = parseResult.matchedOptionValue(name, null)
+
+            // if user specified an array type, return the full array (regardless of 's' suffix on name)
+            Class userSpecifiedType = savedTypeOptions[name]?.type
+            if (userSpecifiedType?.isArray()) { return result }
+
+            // otherwise, if the result is multi-value, return the first value
+            Class derivedType = parseResult.matchedOption(name).type()
+            if (derivedType.isArray()) {
+                return result ? result[0] : null
+            } else if (Collection.class.isAssignableFrom(derivedType)) {
+                return (result as Collection)?.first()
+            }
+            if (!userSpecifiedType && result == '' && parseResult.matchedOption(name).arity().min == 0) {
+                return true
+            }
+            return parseResult.matchedOption(name).typedValues().get(0)
+        }
+        if (parseResult.commandSpec().findOption(name)) { // requested option was not matched: return its default
+            def option = parseResult.commandSpec().findOption(name)
+            def result = option.value
+            return result ? result : false
+        }
+        if (name.size() > 1 && name.endsWith('s')) { // user wants multi-value result
+            def singularName = name[0..-2]
+            if (parseResult.hasMatchedOption(singularName)) {
+                // if picocli has a strongly typed multi-value result, return it
+                Class type = parseResult.matchedOption(singularName).type()
+                if (type.isArray() || Collection.class.isAssignableFrom(type) || Map.class.isAssignableFrom(type)) {
+                    return parseResult.matchedOptionValue(singularName, null)
+                }
+                // otherwise, return the raw string values as a list
+                return parseResult.matchedOption(singularName).stringValues()
+            }
+        }
+        false
+    }
+
+    List<String> arguments() {
+        parseResult.hasMatchedPositional(0) ? parseResult.matchedPositional(0).stringValues() : []
+    }
+}

http://git-wip-us.apache.org/repos/asf/groovy/blob/0803166c/subprojects/groovy-cli-picocli/src/spec/test/builder/CliBuilderTest.groovy
----------------------------------------------------------------------
diff --git a/subprojects/groovy-cli-picocli/src/spec/test/builder/CliBuilderTest.groovy b/subprojects/groovy-cli-picocli/src/spec/test/builder/CliBuilderTest.groovy
new file mode 100644
index 0000000..6bca802
--- /dev/null
+++ b/subprojects/groovy-cli-picocli/src/spec/test/builder/CliBuilderTest.groovy
@@ -0,0 +1,451 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package builder
+
+import groovy.cli.picocli.CliBuilder
+import groovy.cli.Option
+import groovy.cli.TypedOption
+import groovy.cli.Unparsed
+import groovy.transform.TypeChecked
+
+import java.math.RoundingMode
+import java.text.SimpleDateFormat
+import java.util.concurrent.TimeUnit
+import static java.util.concurrent.TimeUnit.DAYS
+import static java.util.concurrent.TimeUnit.HOURS
+
+//import java.math.RoundingMode
+
+class CliBuilderTest extends GroovyTestCase {
+//    void tearDown() {
+//    }
+
+    // tag::annotationInterfaceSpec[]
+    interface GreeterI {
+        @Option(shortName='h', description='display usage') Boolean help()        // <1>
+        @Option(shortName='a', description='greeting audience') String audience() // <2>
+        @Unparsed List remaining()                                                // <3>
+    }
+    // end::annotationInterfaceSpec[]
+
+    // tag::annotationClassSpec[]
+    class GreeterC {
+        @Option(shortName='h', description='display usage')
+        Boolean help                        // <1>
+
+        private String audience
+        @Option(shortName='a', description='greeting audience')
+        void setAudience(String audience) { // <2>
+            this.audience = audience
+        }
+        String getAudience() { audience }
+
+        @Unparsed
+        List remaining                      // <3>
+    }
+    // end::annotationClassSpec[]
+
+    void testAnnotationsInterface() {
+        // tag::annotationInterface[]
+        def cli = new CliBuilder(usage: 'groovy Greeter [option]')  // <1>
+        def argz = '--audience Groovologist'.split()
+        def options = cli.parseFromSpec(GreeterI, argz)             // <2>
+        assert options.audience() == 'Groovologist'                 // <3>
+
+        argz = '-h Some Other Args'.split()
+        options = cli.parseFromSpec(GreeterI, argz)                 // <4>
+        assert options.help()
+        assert options.remaining() == ['Some', 'Other', 'Args']     // <5>
+        // end::annotationInterface[]
+    }
+
+    void testAnnotationsClass() {
+        // tag::annotationClass[]
+        def cli = new CliBuilder(usage: 'groovy Greeter [option]') // <1>
+        def options = new GreeterC()                               // <2>
+        def argz = '--audience Groovologist foo'.split()
+        cli.parseFromInstance(options, argz)                       // <3>
+        assert options.audience == 'Groovologist'                  // <4>
+        assert options.remaining == ['foo']                        // <5>
+        // end::annotationClass[]
+    }
+
+    void testParseScript() {
+        def argz = '--audience Groovologist foo'.split()
+        new GroovyShell().run('''
+            // tag::annotationScript[]
+            import groovy.cli.picocli.CliBuilder
+            import groovy.cli.OptionField
+            import groovy.cli.UnparsedField
+
+            @OptionField String audience
+            @OptionField Boolean help
+            @UnparsedField List remaining
+            new CliBuilder().parseFromInstance(this, args)
+            assert audience == 'Groovologist'
+            assert remaining == ['foo']
+            // end::annotationScript[]
+        ''', 'TestScript.groovy', argz)
+    }
+
+    void testWithArgument() {
+        // tag::withArgument[]
+        def cli = new CliBuilder()
+        cli.a(args: 0, 'a arg') // <1>
+        cli.b(args: 1, 'b arg') // <2>
+        cli.c(args: 1, optionalArg: true, 'c arg') // <3>
+        def options = cli.parse('-a -b foo -c bar baz'.split()) // <4>
+
+        assert options.a == true
+        assert options.b == 'foo'
+        assert options.c == 'bar'
+        assert options.arguments() == ['baz']
+
+        options = cli.parse('-a -c -b foo bar baz'.split()) // <5>
+
+        assert options.a == true
+        assert options.c == true
+        assert options.b == 'foo'
+        assert options.arguments() == ['bar', 'baz']
+        // end::withArgument[]
+    }
+
+    // tag::withArgumentInterfaceSpec[]
+    interface WithArgsI {
+        @Option boolean a()
+        @Option String b()
+        @Option(optionalArg=true) String[] c()
+        @Unparsed List remaining()
+    }
+    // end::withArgumentInterfaceSpec[]
+
+    void testWithArgumentInterface() {
+        // tag::withArgumentInterface[]
+        def cli = new CliBuilder()
+        def options = cli.parseFromSpec(WithArgsI, '-a -b foo -c bar baz'.split())
+        assert options.a()
+        assert options.b() == 'foo'
+        assert options.c() == ['bar']
+        assert options.remaining() == ['baz']
+
+        options = cli.parseFromSpec(WithArgsI, '-a -c -b foo bar baz'.split())
+        assert options.a()
+        assert options.c() == []
+        assert options.b() == 'foo'
+        assert options.remaining() == ['bar', 'baz']
+        // end::withArgumentInterface[]
+    }
+
+    void testMultipleArgsAndOptionalValueSeparator() {
+        // tag::multipleArgs[]
+        def cli = new CliBuilder()
+        cli.a(args: 2, 'a-arg')
+        cli.b(args: '2', valueSeparator: ',', 'b-arg') // <1>
+        cli.c(args: '+', valueSeparator: ',', 'c-arg') // <2>
+
+        def options = cli.parse('-a 1 2 3 4'.split()) // <3>
+        assert options.a == '1' // <4>
+        assert options.as == ['1', '2'] // <5>
+        assert options.arguments() == ['3', '4']
+
+        options = cli.parse('-a1 -a2 3'.split()) // <6>
+        assert options.as == ['1', '2']
+        assert options.arguments() == ['3']
+
+        options = cli.parse(['-b1,2']) // <7>
+        assert options.bs == ['1', '2']
+
+        options = cli.parse(['-c', '1'])
+        assert options.cs == ['1']
+
+        options = cli.parse(['-c1'])
+        assert options.cs == ['1']
+
+        options = cli.parse(['-c1,2,3'])
+        assert options.cs == ['1', '2', '3']
+        // end::multipleArgs[]
+    }
+
+    // tag::multipleArgsInterfaceSpec[]
+    interface ValSepI {
+        @Option(numberOfArguments=2) String[] a()
+        @Option(numberOfArgumentsString='2', valueSeparator=',') String[] b()
+        @Option(numberOfArgumentsString='+', valueSeparator=',') String[] c()
+        @Unparsed remaining()
+    }
+    // end::multipleArgsInterfaceSpec[]
+
+    void testMultipleArgsAndOptionalValueSeparatorInterface() {
+        // tag::multipleArgsInterface[]
+        def cli = new CliBuilder()
+
+        def options = cli.parseFromSpec(ValSepI, '-a 1 2 3 4'.split())
+        assert options.a() == ['1', '2']
+        assert options.remaining() == ['3', '4']
+
+        options = cli.parseFromSpec(ValSepI, '-a1 -a2 3'.split())
+        assert options.a() == ['1', '2']
+        assert options.remaining() == ['3']
+
+        options = cli.parseFromSpec(ValSepI, ['-b1,2'] as String[])
+        assert options.b() == ['1', '2']
+
+        options = cli.parseFromSpec(ValSepI, ['-c', '1'] as String[])
+        assert options.c() == ['1']
+
+        options = cli.parseFromSpec(ValSepI, ['-c1'] as String[])
+        assert options.c() == ['1']
+
+        options = cli.parseFromSpec(ValSepI, ['-c1,2,3'] as String[])
+        assert options.c() == ['1', '2', '3']
+        // end::multipleArgsInterface[]
+    }
+
+    void testType() {
+        // tag::withType[]
+        def argz = '''-a John -b -d 21 -e 1980 -f 3.5 -g 3.14159
+            -h cv.txt -i DOWN and some more'''.split()
+        def cli = new CliBuilder()
+        cli.a(type: String, 'a-arg')
+        cli.b(type: boolean, 'b-arg')
+        cli.c(type: Boolean, 'c-arg')
+        cli.d(type: int, 'd-arg')
+        cli.e(type: Long, 'e-arg')
+        cli.f(type: Float, 'f-arg')
+        cli.g(type: BigDecimal, 'g-arg')
+        cli.h(type: File, 'h-arg')
+        cli.i(type: RoundingMode, 'i-arg')
+        def options = cli.parse(argz)
+        assert options.a == 'John'
+        assert options.b
+        assert !options.c
+        assert options.d == 21
+        assert options.e == 1980L
+        assert options.f == 3.5f
+        assert options.g == 3.14159
+        assert options.h == new File('cv.txt')
+        assert options.i == RoundingMode.DOWN
+        assert options.arguments() == ['and', 'some', 'more']
+        // end::withType[]
+    }
+
+    void testTypeMultiple() {
+        // tag::withTypeMultiple[]
+        def argz = '''-j 3 4 5 -k1.5,2.5,3.5 and some more'''.split()
+        def cli = new CliBuilder()
+        cli.j(args: 3, type: int[], 'j-arg')
+        cli.k(args: '+', valueSeparator: ',', type: BigDecimal[], 'k-arg')
+        def options = cli.parse(argz)
+        assert options.js == [3, 4, 5] // <1>
+        assert options.j == [3, 4, 5]  // <1>
+        assert options.k == [1.5, 2.5, 3.5]
+        assert options.arguments() == ['and', 'some', 'more']
+        // end::withTypeMultiple[]
+    }
+
+    void testConvert() {
+        // tag::withConvert[]
+        def argz = '''-a John -b Mary -d 2016-01-01 and some more'''.split()
+        def cli = new CliBuilder()
+        def lower = { it.toLowerCase() }
+        cli.a(convert: lower, 'a-arg')
+        cli.b(convert: { it.toUpperCase() }, 'b-arg')
+        cli.d(convert: { new SimpleDateFormat("yyyy-MM-dd").parse(it) }, 'd-arg')
+        def options = cli.parse(argz)
+        assert options.a == 'john'
+        assert options.b == 'MARY'
+        assert new SimpleDateFormat("dd-MMM-yyyy").format(options.d) == '01-Jan-2016'
+        assert options.arguments() == ['and', 'some', 'more']
+        // end::withConvert[]
+    }
+
+    // tag::withConvertInterfaceSpec[]
+    interface WithConvertI {
+        @Option(convert={ it.toLowerCase() }) String a()
+        @Option(convert={ it.toUpperCase() }) String b()
+        @Option(convert={ new SimpleDateFormat("yyyy-MM-dd").parse(it) }) Date d()
+        @Unparsed List remaining()
+    }
+    // end::withConvertInterfaceSpec[]
+
+    void testConvertInterface() {
+        // tag::withConvertInterface[]
+        Date newYears = new SimpleDateFormat("yyyy-MM-dd").parse("2016-01-01")
+        def argz = '''-a John -b Mary -d 2016-01-01 and some more'''.split()
+        def cli = new CliBuilder()
+        def options = cli.parseFromSpec(WithConvertI, argz)
+        assert options.a() == 'john'
+        assert options.b() == 'MARY'
+        assert options.d() == newYears
+        assert options.remaining() == ['and', 'some', 'more']
+        // end::withConvertInterface[]
+    }
+
+    void testDefaultValue() {
+        // tag::withDefaultValue[]
+        def cli = new CliBuilder()
+        cli.f longOpt: 'from', type: String, args: 1, defaultValue: 'one', 'f option'
+        cli.t longOpt: 'to', type: int, defaultValue: '35', 't option'
+
+        def options = cli.parse('-f two'.split())
+        assert options.hasOption('f')
+        assert options.f == 'two'
+        assert !options.hasOption('t')
+        assert options.t == 35
+
+        options = cli.parse('-t 45'.split())
+        assert !options.hasOption('from')
+        assert options.from == 'one'
+        assert options.hasOption('to')
+        assert options.to == 45
+        // end::withDefaultValue[]
+    }
+
+    // tag::withDefaultValueInterfaceSpec[]
+    interface WithDefaultValueI {
+        @Option(shortName='f', defaultValue='one') String from()
+        @Option(shortName='t', defaultValue='35') int to()
+    }
+    // end::withDefaultValueInterfaceSpec[]
+
+    void testDefaultValueInterface() {
+        // tag::withDefaultValueInterface[]
+        def cli = new CliBuilder()
+
+        def options = cli.parseFromSpec(WithDefaultValueI, '-f two'.split())
+        assert options.from() == 'two'
+        assert options.to() == 35
+
+        options = cli.parseFromSpec(WithDefaultValueI, '-t 45'.split())
+        assert options.from() == 'one'
+        assert options.to() == 45
+        // end::withDefaultValueInterface[]
+    }
+
+    // tag::withTypeCheckedInterfaceSpec[]
+    interface TypeCheckedI{
+        @Option String name()
+        @Option int age()
+        @Unparsed List remaining()
+    }
+    // end::withTypeCheckedInterfaceSpec[]
+
+    // tag::withTypeCheckedInterface[]
+    @TypeChecked
+    void testTypeCheckedInterface() {
+        def argz = "--name John --age 21 and some more".split()
+        def cli = new CliBuilder()
+        def options = cli.parseFromSpec(TypeCheckedI, argz)
+        String n = options.name()
+        int a = options.age()
+        assert n == 'John' && a == 21
+        assert options.remaining() == ['and', 'some', 'more']
+    }
+    // end::withTypeCheckedInterface[]
+
+    // tag::withTypeChecked[]
+    @TypeChecked
+    void testTypeChecked() {
+        def cli = new CliBuilder(acceptLongOptionsWithSingleHyphen: true)
+        TypedOption<String> name = cli.option(String, opt: 'n', longOpt: 'name', 'name option')
+        TypedOption<Integer> age = cli.option(Integer, longOpt: 'age', 'age option')
+        def argz = "--name John -age 21 and some more".split()
+        def options = cli.parse(argz)
+        String n = options[name]
+        int a = options[age]
+        assert n == 'John' && a == 21
+        assert options.arguments() == ['and', 'some', 'more']
+    }
+    // end::withTypeChecked[]
+
+    @TypeChecked
+    void testTypeChecked_defaultOnlyDoubleHyphen() {
+        def cli = new CliBuilder()
+        TypedOption<String> name = cli.option(String, opt: 'n', longOpt: 'name', 'name option')
+        TypedOption<Integer> age = cli.option(Integer, longOpt: 'age', 'age option')
+        def argz = "--name John -age 21 and some more".split()
+        def options = cli.parse(argz)
+        assert options[name] == 'John'
+        assert options[age] == null
+        assert options.arguments() == ['-age', '21', 'and', 'some', 'more']
+    }
+
+    void testUsageMessageSpec() {
+        // suppress ANSI escape codes to make this test pass on all environments
+        System.setProperty("picocli.ansi", "false")
+        ByteArrayOutputStream baos = new ByteArrayOutputStream()
+        System.setOut(new PrintStream(baos, true))
+
+        // tag::withUsageMessageSpec[]
+        def cli = new CliBuilder()
+        cli.name = "myapp"
+        cli.usageMessage.with {
+            headerHeading("@|bold,underline Header heading:|@%n")
+            header("Header 1", "Header 2")                     // before the synopsis
+            synopsisHeading("%n@|bold,underline Usage:|@ ")
+            descriptionHeading("%n@|bold,underline Description heading:|@%n")
+            description("Description 1", "Description 2")      // after the synopsis
+            optionListHeading("%n@|bold,underline Options heading:|@%n")
+            footerHeading("%n@|bold,underline Footer heading:|@%n")
+            footer("Footer 1", "Footer 2")
+        }
+        cli.a('option a description')
+        cli.b('option b description')
+        cli.c(args: '*', 'option c description')
+        cli.usage()
+        // end::withUsageMessageSpec[]
+
+        String expected = '''\
+Header heading:
+Header 1
+Header 2
+
+Usage: myapp [-ab] [-c[=PARAM...]]...
+
+Description heading:
+Description 1
+Description 2
+
+Options heading:
+  -a               option a description
+  -b               option b description
+  -c= [PARAM...]   option c description
+
+Footer heading:
+Footer 1
+Footer 2
+'''
+        assertEquals(expected.normalize(), baos.toString().normalize())
+    }
+
+    public void testMapOption() {
+        // tag::MapOption[]
+        def cli = new CliBuilder()
+        cli.D(args: 2,   valueSeparator: '=', 'the old way')                          // <1>
+        cli.X(type: Map, 'the new way')                                               // <2>
+        cli.Z(type: Map, auxiliaryTypes: [TimeUnit, Integer].toArray(), 'typed map')  // <3>
+
+        def options = cli.parse('-Da=b -Dc=d -Xx=y -Xi=j -ZDAYS=2 -ZHOURS=23'.split())// <4>
+        assert options.Ds == ['a', 'b', 'c', 'd']                                     // <5>
+        assert options.Xs == [ 'x':'y', 'i':'j' ]                                     // <6>
+        assert options.Zs == [ (DAYS as TimeUnit):2, (HOURS as TimeUnit):23 ]         // <7>
+        // end::MapOption[]
+
+    }
+}


Mime
View raw message