You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@groovy.apache.org by pa...@apache.org on 2018/04/27 22:33:30 UTC

[1/2] groovy git commit: GROOVY-8520 add picocli-based CliBuilder (closes #688)

Repository: groovy
Updated Branches:
  refs/heads/GROOVY_2_5_X 582feba6e -> 0803166cd


http://git-wip-us.apache.org/repos/asf/groovy/blob/0803166c/subprojects/groovy-cli-picocli/src/test/groovy/groovy/cli/picocli/CliBuilderTest.groovy
----------------------------------------------------------------------
diff --git a/subprojects/groovy-cli-picocli/src/test/groovy/groovy/cli/picocli/CliBuilderTest.groovy b/subprojects/groovy-cli-picocli/src/test/groovy/groovy/cli/picocli/CliBuilderTest.groovy
new file mode 100644
index 0000000..e6a0e7a
--- /dev/null
+++ b/subprojects/groovy-cli-picocli/src/test/groovy/groovy/cli/picocli/CliBuilderTest.groovy
@@ -0,0 +1,1002 @@
+/*
+ *  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.Option
+import groovy.cli.Unparsed
+import groovy.cli.picocli.CliBuilder
+import groovy.transform.ToString
+import groovy.transform.TypeChecked
+import picocli.CommandLine.DuplicateOptionAnnotationsException
+
+import java.math.RoundingMode
+import java.text.SimpleDateFormat
+
+import static picocli.CommandLine.Model.OptionSpec.builder
+
+/**
+ * Test class for the picocli version of CliBuilder.
+ * <p>
+ * Ensures compatibility with the commons-cli version of CliBuilder and tests
+ * picocli-specific functionality.
+ */
+
+class CliBuilderTest extends GroovyTestCase {
+    /** Commons-cli constant that specifies the number of argument values is infinite */
+    private static final int COMMONS_CLI_UNLIMITED_VALUES = -2;
+
+    private StringWriter stringWriter
+    private PrintWriter printWriter
+
+    void setUp() {
+        resetPrintWriter()
+    }
+
+    private final expectedParameter = 'ASCII'
+    private final usageString = 'groovy [option]* filename'
+
+    private void runSample(optionList) {
+        resetPrintWriter()
+        def cli = new CliBuilder(usage: usageString, writer: printWriter)
+        cli.h(longOpt: 'help', 'usage information')
+        cli.c(argName: 'charset', args: 1, longOpt: 'encoding', 'character encoding')
+        cli.i(argName: 'extension', optionalArg: true, 'modify files in place, create backup if extension is specified (e.g. \'.bak\')')
+        def stringified = cli.commandSpec.toString()
+//        assert stringified =~ /i=\[ option: i  :: modify files in place, create backup if extension is given/
+//        assert stringified =~ /c=\[ option: c encoding  \[ARG] :: character encoding/
+//        assert stringified =~ /h=\[ option: h help  :: usage information/
+//        assert stringified =~ /encoding=\[ option: c encoding  \[ARG] :: character encoding/
+//        assert stringified =~ /help=\[ option: h help  :: usage information/
+        def options = cli.parse(optionList)
+        assert options.hasOption('h')
+        assert options.hasOption('help')
+        assert options.h
+        assert options.help
+        if (options.h) { cli.usage() }
+        def expectedUsage = """Usage: $usageString
+  -c, --encoding=<charset>   character encoding
+  -h, --help                 usage information
+  -i= [<extension>]          modify files in place, create backup if extension is
+                               specified (e.g. '.bak')"""
+        groovy.util.GroovyTestCase.assertEquals(expectedUsage, stringWriter.toString().tokenize('\r\n').join('\n'))
+        resetPrintWriter()
+        cli.writer = printWriter
+        if (options.help) { cli.usage() }
+        groovy.util.GroovyTestCase.assertEquals(expectedUsage, stringWriter.toString().tokenize('\r\n').join('\n'))
+        assert options.hasOption('c')
+        assert options.c
+        assert options.hasOption('encoding')
+        assert options.encoding
+        groovy.util.GroovyTestCase.assertEquals(expectedParameter, options.getOptionValue('c'))
+        groovy.util.GroovyTestCase.assertEquals(expectedParameter, options.c)
+        groovy.util.GroovyTestCase.assertEquals(expectedParameter, options.getOptionValue('encoding'))
+        groovy.util.GroovyTestCase.assertEquals(expectedParameter, options.encoding)
+        groovy.util.GroovyTestCase.assertEquals(false, options.noSuchOptionGiven)
+        junit.framework.TestCase.assertEquals(false, options.hasOption('noSuchOptionGiven'))
+        groovy.util.GroovyTestCase.assertEquals(false, options.x)
+        junit.framework.TestCase.assertEquals(false, options.hasOption('x'))
+    }
+
+    private void resetPrintWriter() {
+        stringWriter = new StringWriter()
+        printWriter = new PrintWriter(stringWriter)
+    }
+
+    void testSampleShort() {
+        runSample(['-h', '-c', expectedParameter])
+    }
+
+    void testSampleLong() {
+        runSample( ['--help', '--encoding', expectedParameter])
+    }
+
+    void testSimpleArg() {
+        def cli = new CliBuilder()
+        cli.a([:], '')
+        def options = cli.parse(['-a', '1', '2'])
+        groovy.util.GroovyTestCase.assertEquals(['1', '2'], options.arguments())
+    }
+
+    void testMultipleArgs() {
+        def cli = new CliBuilder()
+        cli.a(longOpt: 'arg', args: 2, valueSeparator: ',' as char, 'arguments')
+        def options = cli.parse(['-a', '1,2'])
+        groovy.util.GroovyTestCase.assertEquals('1', options.a)
+        groovy.util.GroovyTestCase.assertEquals(['1', '2'], options.as)
+        groovy.util.GroovyTestCase.assertEquals('1', options.arg)
+        groovy.util.GroovyTestCase.assertEquals(['1', '2'], options.args)
+    }
+
+    void testFailedParsePrintsUsage() {
+        def cli = new CliBuilder(writer: printWriter)
+        cli.x(required: true, 'message')
+        cli.parse([])
+        // NB: This test is very fragile and is bound to fail on different locales and versions of commons-cli... :-(
+        assert stringWriter.toString() == String.format(
+                "error: Missing required option '-x=PARAM'%n" +\
+                "Usage: groovy -x%n" +\
+                "  -x           message%n")
+    }
+
+    void testLongOptsOnly_nonOptionShouldStopArgProcessing() {
+        def cli = new CliBuilder()
+        def anOption = builder('anOption').arity("1").description('An option.')
+                .build()
+        cli.commandSpec.addOption(anOption)
+        def options = cli.parse(['-v', '--anOption', 'something'])
+        // no options should be found
+        assert options.getOptionValue('anOption') == null
+        assert !options.anOption
+        assert !options.v
+        // arguments should be still sitting there
+        assert options.arguments() == ['-v', '--anOption', 'something']
+    }
+
+    void testLongAndShortOpts_allOptionsValid() {
+        def cli = new CliBuilder()
+        def anOption = builder('--anOption').arity("1").description('An option.').build()
+        cli.commandSpec.addOption(anOption)
+        cli.v(longOpt: 'verbose', 'verbose mode')
+        def options = cli.parse(['-v', '--anOption', 'something'])
+        assert options.v
+        assert options.getOptionValue('anOption') == 'something'
+        assert options.anOption == 'something'
+        assert !options.arguments()
+    }
+
+    void testUnrecognizedOptions() {
+        def cli = new CliBuilder()
+        cli.v(longOpt: 'verbose', 'verbose mode')
+        def options = cli.parse(['-x', '-yyy', '--zzz', 'something'])
+        groovy.util.GroovyTestCase.assertEquals(['-x', '-yyy', '--zzz', 'something'], options.arguments())
+    }
+
+    void testMultipleOccurrencesSeparateSeparate() {
+        def cli = new CliBuilder()
+        cli.a(longOpt: 'arg', args: COMMONS_CLI_UNLIMITED_VALUES, 'arguments')
+        def options = cli.parse(['-a', '1', '-a', '2', '-a', '3'])
+        groovy.util.GroovyTestCase.assertEquals('1', options.a)
+        groovy.util.GroovyTestCase.assertEquals(['1', '2', '3'], options.as)
+        groovy.util.GroovyTestCase.assertEquals('1', options.arg)
+        groovy.util.GroovyTestCase.assertEquals(['1', '2', '3'], options.args)
+        groovy.util.GroovyTestCase.assertEquals([], options.arguments())
+    }
+
+    void testMandatoryParametersDoNotConsumeOtherOptions() {
+        def cli = new CliBuilder()
+        cli.a(args: 2, 'arguments')
+        cli.b(args: 2, valueSeparator: ',', 'arguments')
+        cli.c(args: '+', valueSeparator: ',', 'arguments')
+
+        def options = cli.parse(['-a', '1', '-a', '2'])
+        junit.framework.TestCase.assertNull(options)
+
+        options = cli.parse(['-a', '1', '-a', '2', '-a', '3'])
+        junit.framework.TestCase.assertNull(options)
+    }
+
+    void testMultipleOccurrencesSeparateSeparate3() {
+        def cli = new CliBuilder()
+//        cli.a(longOpt: 'arg', args: COMMONS_CLI_UNLIMITED_VALUES, 'arguments')
+        cli.a(args: 2, 'arguments')
+        cli.b(args: 2, valueSeparator: ',', 'arguments')
+        cli.c(args: '+', valueSeparator: ',', 'arguments')
+
+        def options = cli.parse(['-a', '1'])
+        junit.framework.TestCase.assertNull(options)
+
+        options = cli.parse(['-a1'])
+        groovy.util.GroovyTestCase.assertEquals('1', options.a)
+        groovy.util.GroovyTestCase.assertEquals(['1'], options.as)
+
+//        options = cli.parse(['-a', '1', '-a', '2']) // TODO
+//        assertNull(options)
+
+        options = cli.parse(['-a1', '-a2'])
+        groovy.util.GroovyTestCase.assertEquals('1', options.a)
+        groovy.util.GroovyTestCase.assertEquals(['1', '2'], options.as)
+
+        options = cli.parse(['-a1', '-a2', '-a3'])
+        groovy.util.GroovyTestCase.assertEquals('1', options.a)
+        groovy.util.GroovyTestCase.assertEquals(['1', '2', '3'], options.as)
+
+//        options = cli.parse(['-a', '1', '-a', '2', '-a', '3'])
+//        assertNull(options)
+
+        options = cli.parse(['-a', '1', '2'])
+        groovy.util.GroovyTestCase.assertEquals('1', options.a)
+        groovy.util.GroovyTestCase.assertEquals(['1', '2'], options.as)
+
+        options = cli.parse(['-a1', '2'])
+        groovy.util.GroovyTestCase.assertEquals('1', options.a)
+        assert options.arguments() == ['2']
+        groovy.util.GroovyTestCase.assertEquals(['1'], options.as)
+
+        options = cli.parse(['-a', '1', '2', '-a', '3', '4'])
+        groovy.util.GroovyTestCase.assertEquals('1', options.a)
+        groovy.util.GroovyTestCase.assertEquals(['1', '2', '3', '4'], options.as)
+
+        options = cli.parse(['-a', '1', '2', '-a3', '-a4', '-a5'])
+        groovy.util.GroovyTestCase.assertEquals('1', options.a)
+        groovy.util.GroovyTestCase.assertEquals(['1', '2', '3', '4', '5'], options.as)
+
+        options = cli.parse(['-a', '1', '2', '-a3', '-a', '4', '5' ])
+        groovy.util.GroovyTestCase.assertEquals('1', options.a)
+        groovy.util.GroovyTestCase.assertEquals(['1', '2', '3', '4', '5'], options.as)
+
+        options = cli.parse(['-a1', '2', '-a3', '4'])
+        groovy.util.GroovyTestCase.assertEquals('1', options.a)
+        assert options.arguments() == ['2', '-a3', '4']
+        //assertEquals(['1', '2', '3', '4'], options.as)
+
+        options = cli.parse(['-b1,2'])
+        assert options.bs == ['1', '2']
+
+        options = cli.parse(['-b1,2,3'])
+        assert options.bs == ['1', '2,3']
+
+        options = cli.parse(['-b', '1,2', '3,4'])
+        assert options.bs == ['1', '2']
+        assert options.arguments() == ['3,4']
+
+        options = cli.parse(['-b', '1,2', '-b', '3,4'])
+        assert options.bs == ['1', '2', '3', '4']
+//        assert options.arguments() == []
+
+        options = cli.parse(['-b', '1', '2', '-b', '3', '4'])
+        assert options.bs == ['1', '2', '3', '4']
+    }
+
+    void testMultipleOccurrencesSeparateJuxtaposed() {
+        def cli = new CliBuilder()
+//        cli.a ( longOpt : 'arg' , args : COMMONS_CLI_UNLIMITED_VALUES , 'arguments' )
+        cli.a(longOpt: 'arg', args: 1, 'arguments')
+        def options = cli.parse(['-a1', '-a2', '-a3'])
+        groovy.util.GroovyTestCase.assertEquals('1', options.a)
+        groovy.util.GroovyTestCase.assertEquals(['1', '2', '3'], options.as)
+        groovy.util.GroovyTestCase.assertEquals('1', options.arg)
+        groovy.util.GroovyTestCase.assertEquals(['1', '2', '3'], options.args)
+        groovy.util.GroovyTestCase.assertEquals([], options.arguments())
+    }
+
+    void testMultipleOccurrencesTogetherSeparate() {
+        def cli = new CliBuilder()
+        cli.a(longOpt: 'arg', args: COMMONS_CLI_UNLIMITED_VALUES, valueSeparator: ',' as char, 'arguments')
+        def options = cli.parse(['-a 1,2,3'])
+        groovy.util.GroovyTestCase.assertEquals(' 1', options.a)
+        groovy.util.GroovyTestCase.assertEquals([' 1', '2', '3'], options.as)
+        groovy.util.GroovyTestCase.assertEquals(' 1', options.arg)
+        groovy.util.GroovyTestCase.assertEquals([' 1', '2', '3'], options.args)
+        groovy.util.GroovyTestCase.assertEquals([], options.arguments())
+    }
+
+    void testMultipleOccurrencesTogetherJuxtaposed() {
+        def cli1 = new CliBuilder()
+        cli1.a(longOpt: 'arg', args: COMMONS_CLI_UNLIMITED_VALUES, valueSeparator: ',' as char, 'arguments')
+        def options = cli1.parse(['-a1,2,3'])
+        groovy.util.GroovyTestCase.assertEquals('1', options.a)
+        groovy.util.GroovyTestCase.assertEquals(['1', '2', '3'], options.as)
+        groovy.util.GroovyTestCase.assertEquals('1', options.arg)
+        groovy.util.GroovyTestCase.assertEquals(['1', '2', '3'], options.args)
+        groovy.util.GroovyTestCase.assertEquals([], options.arguments()) }
+
+    /*
+     *  Behaviour with unrecognized options.
+     */
+
+    void testUnrecognizedOptionSilentlyIgnored_GnuParser() {
+        def cli = new CliBuilder(usage: usageString, writer: printWriter)
+        def options = cli.parse(['-v'])
+        groovy.util.GroovyTestCase.assertEquals('''''', stringWriter.toString().tokenize('\r\n').join('\n'))
+        assert !options.v
+    }
+
+    private void checkNoOutput() {
+        assert stringWriter.toString().tokenize('\r\n').join('\n') == ''''''
+    }
+
+    void testUnrecognizedOptionSilentlyIgnored_DefaultParser() {
+        def cli = new CliBuilder(usage: usageString, writer: printWriter/*, parser: new DefaultParser()*/)
+        def options = cli.parse(['-v'])
+        checkNoOutput()
+        assert !options.v
+    }
+
+    void testUnrecognizedOptionTerminatesParse_GnuParser() {
+        def cli = new CliBuilder(usage: usageString, writer: printWriter/*, parser: new GnuParser()*/)
+        cli.h(longOpt: 'help', 'usage information')
+        def options = cli.parse(['-v', '-h'])
+        checkNoOutput()
+        assert !options.v
+        assert !options.h
+        groovy.util.GroovyTestCase.assertEquals(['-v', '-h'], options.arguments())
+    }
+
+    void testUnrecognizedOptionTerminatesParse_DefaultParser() {
+        def cli = new CliBuilder(usage: usageString, writer: printWriter/*, parser: new DefaultParser()*/)
+        cli.h(longOpt: 'help', 'usage information')
+        def options = cli.parse(['-v', '-h'])
+        checkNoOutput()
+        assert !options.v
+        assert !options.h
+        groovy.util.GroovyTestCase.assertEquals(['-v', '-h'], options.arguments())
+    }
+
+    void testMultiCharShortOpt() {
+        def cli = new CliBuilder(writer: printWriter)
+        cli.abc('abc option')
+        cli.def(longOpt: 'defdef', 'def option')
+        def options = cli.parse(['-abc', '--defdef', 'ghi'])
+        assert options
+        assert options.arguments() == ['ghi']
+        assert options.abc && options.def && options.defdef
+        checkNoOutput()
+    }
+
+    void testArgumentBursting_DefaultParserOnly() {
+        def cli = new CliBuilder(writer: printWriter)
+        // must not have longOpt 'abc' and also no args for a or b
+        cli.a('a')
+        cli.b('b')
+        cli.c('c')
+        def options = cli.parse(['-abc', '-d'])
+        assert options
+        assert options.arguments() == ['-d']
+        assert options.a && options.b && options.c && !options.d
+        checkNoOutput()
+    }
+
+    void testLongOptEndingWithS() {
+        def cli = new CliBuilder()
+        cli.s(longOpt: 'number_of_seconds', 'a long arg that ends with an "s"')
+
+        def options = cli.parse(['-s'])
+
+        assert options.hasOption('s')
+        assert options.hasOption('number_of_seconds')
+        assert options.s
+        assert options.number_of_seconds
+    }
+
+    void testArgumentFileExpansion() {
+        def cli = new CliBuilder(usage: 'test usage')
+        cli.h(longOpt: 'help', 'usage information')
+        cli.d(longOpt: 'debug', 'turn on debug info')
+        def args = ['-h', '@temp.args', 'foo', '@@baz']
+        def temp = new File('temp.args')
+        temp.deleteOnExit()
+        temp.text = '-d bar'
+        def options = cli.parse(args)
+        assert options.h
+        assert options.d
+        assert options.arguments() == ['bar', 'foo', '@baz']
+    }
+
+    void testArgumentFileExpansionArgOrdering() {
+        def cli = new CliBuilder(usage: 'test usage')
+        def args = ['one', '@temp1.args', 'potato', '@temp2.args', 'four']
+        def temp1 = new File('temp1.args')
+        temp1.deleteOnExit()
+        temp1.text = 'potato two'
+        def temp2 = new File('temp2.args')
+        temp2.deleteOnExit()
+        temp2.text = 'three potato'
+        def options = cli.parse(args)
+        assert options.arguments() == 'one potato two potato three potato four'.split()
+    }
+
+    void testArgumentFileExpansionTurnedOff() {
+        def cli = new CliBuilder(usage: 'test usage', expandArgumentFiles:false)
+        cli.h(longOpt: 'help', 'usage information')
+        cli.d(longOpt: 'debug', 'turn on debug info')
+        def args = ['-h', '@temp.args', 'foo', '@@baz']
+        def temp = new File('temp.args')
+        temp.deleteOnExit()
+        temp.text = '-d bar'
+        def options = cli.parse(args)
+        assert options.h
+        assert !options.d
+        assert options.arguments() == ['@temp.args', 'foo', '@@baz']
+    }
+
+    void testGStringSpecification_Groovy4621() {
+        def user = 'scott'
+        def pass = 'tiger'
+        def ignore = false
+        def longOptName = 'user'
+        def cli = new CliBuilder(usage: 'blah')
+        cli.dbusername(longOpt:"$longOptName", args: 1, "Database username [default $user]")
+        cli.dbpassword(args: 1, "Database password [default $pass]")
+        cli.i("ignore case [default $ignore]")
+        def args = ['-dbpassword', 'foo', '--user', 'bar', '-i']
+        def options = cli.parse(args)
+        assert options.user == 'bar'
+        assert options.dbusername == 'bar'
+        assert options.dbpassword == 'foo'
+        assert options.i
+    }
+
+    void testNoExpandArgsWithEmptyArg() {
+        def cli = new CliBuilder(expandArgumentFiles: false)
+        cli.parse(['something', ''])
+    }
+
+    void testExpandArgsWithEmptyArg() {
+        def cli = new CliBuilder(expandArgumentFiles: true)
+        cli.parse(['something', ''])
+    }
+
+    void testDoubleHyphenShortOptions() {
+        def cli = new CliBuilder()
+        cli.a([:], '')
+        cli.b([:], '')
+        def options = cli.parse(['-a', '--', '-b', 'foo'])
+        assert options.arguments() == ['-b', 'foo']
+    }
+
+    void testDoubleHyphenLongOptions() {
+        def cli = new CliBuilder()
+        cli._([longOpt:'alpha'], '')
+        cli._([longOpt:'beta'], '')
+        def options = cli.parse(['--alpha', '--', '--beta', 'foo'])
+        assert options.alpha
+        assert options.arguments() == ['--beta', 'foo']
+    }
+
+    void testMixedShortAndLongOptions() {
+        def cli = new CliBuilder()
+        cli.a([longOpt:'alpha', args:1], '')
+        cli.b([:], '')
+        def options = cli.parse(['-b', '--alpha', 'param', 'foo'])
+        assert options.a == 'param'
+        assert options.arguments() == ['foo']
+    }
+
+    void testMixedBurstingAndLongOptions() {
+        def cli = new CliBuilder()
+        cli.a([:], '')
+        cli.b([:], '')
+        cli.c([:], '')
+        cli.d([longOpt:'abacus'], '')
+        def options = cli.parse(['-abc', 'foo'])
+        assert options.a
+        assert options.b
+        assert options.c
+        assert options.arguments() == ['foo']
+        options = cli.parse(['--abacus', 'foo'])
+        assert !options.a
+        assert !options.b
+        assert !options.c
+        assert options.d
+        assert options.arguments() == ['foo']
+
+        //this passed in previous version of CliBuilder:
+        // longOpt may have 1 or 2 hyphens
+        resetPrintWriter()
+        cli.writer = printWriter
+        options = cli.parse(['-abacus', 'foo'])
+        assert options == null
+        assertTrue(stringWriter.toString().startsWith('error: Unmatched argument [-us]'))
+    }
+
+    void testMixedBurstingAndLongOptions_singleHyphen() {
+        def cli = new CliBuilder()
+        cli.acceptLongOptionsWithSingleHyphen = true
+
+        cli.a([:], '')
+        cli.b([:], '')
+        cli.c([:], '')
+        cli.d([longOpt:'abacus'], '')
+        def options = cli.parse(['-abc', 'foo'])
+        assert options.a
+        assert options.b
+        assert options.c
+        assert options.arguments() == ['foo']
+        options = cli.parse(['--abacus', 'foo'])
+        assert !options.a
+        assert !options.b
+        assert !options.c
+        assert options.d
+        assert options.arguments() == ['foo']
+
+        //this passed in previous version of CliBuilder:
+        // longOpt may have 1 or 2 hyphens
+        options = cli.parse(['-abacus', 'foo'])
+        assert !options.a
+        assert !options.b
+        assert !options.c
+        assert options.d
+        assert options.arguments() == ['foo']
+    }
+
+    interface PersonI {
+        @Option String first()
+        @Option String last()
+        @Option boolean flag1()
+        @Option Boolean flag2()
+        @Option(longName = 'specialFlag') Boolean flag3()
+        @Option flag4()
+        @Option int age()
+        @Option Integer born()
+        @Option float discount()
+        @Option BigDecimal pi()
+        @Option File biography()
+        @Option RoundingMode roundingMode()
+        @Unparsed List remaining()
+    }
+
+    def argz = "--first John --last Smith --flag1 --flag2 --specialFlag --age  21 --born 1980 --discount 3.5 --pi 3.14159 --biography cv.txt --roundingMode DOWN and some more".split()
+
+    void testParseFromSpec() {
+        def builder1 = new CliBuilder()
+        def p1 = builder1.parseFromSpec(PersonI, argz)
+        assert p1.first() == 'John'
+        assert p1.last() == 'Smith'
+        assert p1.flag1()
+        assert p1.flag2()
+        assert p1.flag3()
+        assert !p1.flag4()
+        assert p1.born() == 1980
+        assert p1.age() == 21
+        assert p1.discount() == 3.5f
+        assert p1.pi() == 3.14159
+        assert p1.biography() == new File('cv.txt')
+        assert p1.roundingMode() == RoundingMode.DOWN
+        assert p1.remaining() == ['and', 'some', 'more']
+    }
+
+    @ToString(includeFields=true, includePackage=false)
+    class PersonC {
+        @Option String first
+        private String last
+        @Option boolean flag1
+        private Boolean flag2
+        private Boolean flag3
+        private Boolean flag4
+        private int age
+        private Integer born
+        private float discount
+        private BigDecimal pi
+        private File biography
+        private RoundingMode roundingMode
+        private List remaining
+
+        @Option void setLast(String last) {
+            this.last = last
+        }
+        @Option void setFlag2(boolean flag2) {
+            this.flag2 = flag2
+        }
+        @Option(longName = 'specialFlag') void setFlag3(boolean flag3) {
+            this.flag3 = flag3
+        }
+        @Option void setFlag4(boolean flag4) {
+            this.flag4 = flag4
+        }
+        @Option void setAge(int age) {
+            this.age = age
+        }
+        @Option void setBorn(Integer born) {
+            this.born = born
+        }
+        @Option void setDiscount(float discount) {
+            this.discount = discount
+        }
+        @Option void setPi(BigDecimal pi) {
+            this.pi = pi
+        }
+        @Option void setBiography(File biography) {
+            this.biography = biography
+        }
+        @Option void setRoundingMode(RoundingMode roundingMode) {
+            this.roundingMode = roundingMode
+        }
+        @Unparsed void setRemaining(List remaining) {
+            this.remaining = remaining
+        }
+    }
+    class DefaultValueC {
+        @Option(shortName='f', defaultValue='one') String from
+        @Option(shortName='t', defaultValue='35') int to
+        @Option(shortName='b') int by = 1
+    }
+
+    void testDefaultValueClass() {
+        def cli = new CliBuilder()
+        def options = new DefaultValueC()
+        cli.parseFromInstance(options, '-f two'.split())
+        assert options.from == 'two'
+        assert options.to == 35
+        assert options.by == 1
+
+        options = new DefaultValueC()
+        cli.parseFromInstance(options, '-t 45 --by 2'.split())
+        assert options.from == 'one'
+        assert options.to == 45
+        assert options.by == 2
+    }
+
+    class ValSepC {
+        @Option(numberOfArguments=2) String[] a
+        @Option(numberOfArgumentsString='2', valueSeparator=',') String[] b
+        @Option(numberOfArgumentsString='+', valueSeparator=',') String[] c
+        @Unparsed remaining
+    }
+
+    void testValSepClass() {
+        def cli = new CliBuilder()
+
+        def options = new ValSepC()
+        cli.parseFromInstance(options, '-a 1 2 3 4'.split())
+        assert options.a == ['1', '2']
+        assert options.remaining == ['3', '4']
+
+        options = new ValSepC()
+        cli.parseFromInstance(options, '-a 1 2 -a 3 4'.split())
+        assert options.a == ['1', '2', '3', '4']
+//        assert options.remaining == []
+
+        options = new ValSepC()
+        cli.parseFromInstance(options, '-a1 -a2 3'.split())
+        assert options.a == ['1', '2']
+        assert options.remaining == ['3']
+
+        options = new ValSepC()
+        cli.parseFromInstance(options, ['-b1,2'] as String[])
+        assert options.b == ['1', '2']
+
+        options = new ValSepC()
+        cli.parseFromInstance(options, ['-b1,2,3'] as String[])
+        assert options.b == ['1', '2,3']
+
+        options = new ValSepC()
+        cli.parseFromInstance(options, ['-c', '1'] as String[])
+        assert options.c == ['1']
+
+        options = new ValSepC()
+        cli.parseFromInstance(options, ['-c1'] as String[])
+        assert options.c == ['1']
+
+        options = new ValSepC()
+        cli.parseFromInstance(options, ['-c1,2,3'] as String[])
+        assert options.c == ['1', '2', '3']
+    }
+
+    class WithConvertC {
+        @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
+    }
+
+    void testConvertClass() {
+        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 = new WithConvertC()
+        cli.parseFromInstance(options, argz)
+        assert options.a == 'john'
+        assert options.b == 'MARY'
+        assert options.d == newYears
+        assert options.remaining == ['and', 'some', 'more']
+    }
+
+    class TypeCheckedC {
+        @Option String name
+        @Option int age
+        @Unparsed List remaining
+    }
+
+    @TypeChecked
+    void testTypeCheckedClass() {
+        def argz = "--name John --age 21 and some more".split()
+        def cli = new CliBuilder()
+        def options = new TypeCheckedC()
+        cli.parseFromInstance(options, argz)
+        String n = options.name
+        int a = options.age
+        assert n == 'John' && a == 21
+        assert options.remaining == ['and', 'some', 'more']
+    }
+
+    void testParseFromInstance() {
+        def p2 = new PersonC()
+        def builder2 = new CliBuilder()
+        builder2.parseFromInstance(p2, argz)
+        // properties show first in toString()
+        assert p2.toString() == 'CliBuilderTest$PersonC(John, true, Smith, true, true, false, 21, 1980, 3.5, 3.14159,' +
+                ' cv.txt, DOWN, [and, some, more])'
+    }
+
+    interface RetTypeI {
+        @Unparsed Integer[] nums()
+    }
+
+    // this feature is incubating
+    void testTypedUnparsedFromSpec() {
+        def argz = '12 34 56'.split()
+        def cli = new CliBuilder()
+        def options = cli.parseFromSpec(RetTypeI, argz)
+        assert options.nums() == [12, 34, 56]
+    }
+
+    class RetTypeC {
+        @Unparsed Integer[] nums
+    }
+
+    // this feature is incubating
+    void testTypedUnparsedFromInstance() {
+        def argz = '12 34 56'.split()
+        def cli = new CliBuilder()
+        def options = new RetTypeC()
+        cli.parseFromInstance(options, argz)
+        assert options.nums == [12, 34, 56]
+    }
+
+    interface FlagEdgeCasesI {
+        @Option boolean abc()
+        @Option(numberOfArgumentsString='1') boolean efg()
+        @Option(numberOfArguments=1) ijk()
+        @Option(numberOfArguments=0) lmn()
+        @Unparsed List remaining()
+    }
+
+    void testParseFromInstanceFlagEdgeCases_singleHyphen() {
+        def cli = new CliBuilder(acceptLongOptionsWithSingleHyphen: true)
+        def options = cli.parseFromSpec(FlagEdgeCasesI, '-abc -efg true -ijk foo -lmn bar baz'.split())
+
+        assert options.abc() && options.efg()
+        assert options.ijk() == 'foo'
+        assert options.lmn() == true
+        assert options.remaining() == ['bar', 'baz']
+
+        options = cli.parseFromSpec(FlagEdgeCasesI, '-abc -ijk cat -efg false bar baz'.split())
+        assert options.abc()
+        assert options.ijk() == 'cat'
+        assert !options.efg()
+        assert options.lmn() == false
+        assert options.remaining() == ['bar', 'baz']
+    }
+
+    void testParseFromInstanceFlagEdgeCases_doubleHyphen() {
+        def cli = new CliBuilder()
+        def options = cli.parseFromSpec(FlagEdgeCasesI, '--abc --efg true --ijk foo --lmn bar baz'.split())
+
+        assert options.abc() && options.efg()
+        assert options.ijk() == 'foo'
+        assert options.lmn() == true
+        assert options.remaining() == ['bar', 'baz']
+
+        options = cli.parseFromSpec(FlagEdgeCasesI, '--abc --ijk cat --efg false bar baz'.split())
+        assert options.abc()
+        assert options.ijk() == 'cat'
+        assert !options.efg()
+        assert options.lmn() == false
+        assert options.remaining() == ['bar', 'baz']
+    }
+
+    void testParseScript() {
+        new GroovyShell().run('''
+            import groovy.cli.OptionField
+            import groovy.cli.UnparsedField
+            import groovy.cli.picocli.CliBuilder
+            import java.math.RoundingMode
+            
+            @OptionField String first
+            @OptionField String last
+            @OptionField boolean flag1
+            @OptionField Boolean flag2
+            @OptionField(longName = 'specialFlag') Boolean flag3
+            @OptionField Boolean flag4
+            @OptionField int age
+            @OptionField Integer born
+            @OptionField float discount
+            @OptionField BigDecimal pi
+            @OptionField File biography
+            @OptionField RoundingMode roundingMode
+            @UnparsedField List remaining
+            new CliBuilder().parseFromInstance(this, args)
+            assert first == 'John'
+            assert last == 'Smith'
+            assert flag1
+            assert flag2
+            assert flag3
+            assert !flag4
+            assert born == 1980
+            assert age == 21
+            assert discount == 3.5f
+            assert pi == 3.14159
+            assert biography == new File('cv.txt')
+            assert roundingMode == RoundingMode.DOWN
+            assert remaining == ['and', 'some', 'more']
+        ''', 'CliBuilderTestScript.groovy', argz)
+    }
+
+    public void testOptionProperties() {
+        CliBuilder cli = new CliBuilder(usage: 'groovyConsole [options] [filename]', stopAtNonOption: false)
+        cli.with {
+            D(longOpt: 'define', args: 2, argName: 'name=value', valueSeparator: '=', 'description')
+        }
+        OptionAccessor options = cli.parse('-Dk=v -Dk2=v2'.split())
+        assert options.hasOption('D')
+        Properties props = options.getOptionProperties('D')
+        assert 'v' == props.getProperty('k')
+        assert 'v2' == props.getProperty('k2')
+    }
+
+    public void testAcceptLongOptionsWithSingleHyphen_defaultFalse() {
+        assert !new CliBuilder().acceptLongOptionsWithSingleHyphen
+    }
+
+    public void testAcceptLongOptionsWithSingleHyphen_DuplicateOptionAnnotationsException() {
+        CliBuilder cli = new CliBuilder(acceptLongOptionsWithSingleHyphen: true)
+        try {
+            cli.with {
+                classpath('description')
+                cp(longOpt: 'classpath', 'description')
+            }
+        } catch (DuplicateOptionAnnotationsException expected) {
+            assert expected.message == 'Option name \'-classpath\' is used by both option --classpath and option -classpath'
+        }
+    }
+
+    public void testLongOptionsRequireDoubleHyphenByDefault() {
+        CliBuilder cli = new CliBuilder()
+        cli.with {
+            classpath('description')
+            cp(longOpt: 'classpath', 'cli.option.cp.description')
+            h(longOpt: 'help', 'cli.option.help.description')
+            V(longOpt: 'version', 'cli.option.version.description')
+            pa(longOpt: 'parameters', 'cli.option.parameters.description')
+            i(longOpt: 'indy', 'cli.option.indy.description')
+            D(longOpt: 'define', args: 2, argName: 'name=value', valueSeparator: '=', 'cli.option.define.description')
+            _(longOpt: 'configscript', args: 1, 'cli.option.configscript.description')
+        }
+
+        assert cli.parse(['--classpath']).cp
+        assert cli.parse(['-cp']).cp
+        assert cli.parse(['-classpath']).classpath
+
+        assert cli.parse(['--parameters']).parameters
+        assert cli.parse(['--parameters']).pa
+
+        def options = cli.parse(['-parameters'])
+        assert !options.parameters
+        assert !options.pa
+        assert options.arguments() == ['-parameters']
+
+        assert cli.parse(['--indy']).indy
+        assert cli.parse(['--indy']).i
+        resetPrintWriter()
+        cli.writer = printWriter
+        assert cli.parse(['-indy']) == null
+        assertTrue(stringWriter.toString().startsWith('error: Unmatched argument [-ndy]'))
+
+        assert cli.parse(['--help']).help
+        assert cli.parse(['--help']).h
+        resetPrintWriter()
+        cli.writer = printWriter
+        assert cli.parse(['-help']) == null
+        assertTrue(stringWriter.toString().startsWith('error: Unmatched argument [-elp]'))
+
+        assert cli.parse(['--version']).version
+        assert cli.parse(['--version']).V
+
+        options = cli.parse(['-version'])
+        assert !options.version
+        assert !options.V
+        assert options.arguments() == ['-version']
+
+        assert cli.parse('--configscript abc'.split()).configscript == 'abc'
+
+        options = cli.parse('-configscript abc'.split())
+        assert !options.configscript
+        assert options.arguments() == ['-configscript', 'abc']
+    }
+
+    public void testAcceptLongOptionsWithSingleHyphen_registersLongOptionsTwice() {
+        CliBuilder cli = new CliBuilder(acceptLongOptionsWithSingleHyphen: true)
+        cli.with {
+            cp(longOpt: 'classpath', 'cli.option.cp.description')
+            h(longOpt: 'help', 'cli.option.help.description')
+            V(longOpt: 'version', 'cli.option.version.description')
+            pa(longOpt: 'parameters', 'cli.option.parameters.description')
+            i(longOpt: 'indy', 'cli.option.indy.description')
+            D(longOpt: 'define', args: 2, argName: 'name=value', valueSeparator: '=', 'cli.option.define.description')
+            _(longOpt: 'configscript', args: 1, 'cli.option.configscript.description')
+        }
+
+        assert cli.parse(['--classpath']).cp
+        assert cli.parse(['-classpath']).cp
+        assert cli.parse(['-cp']).classpath
+
+        assert cli.parse(['--parameters']).pa
+        assert cli.parse(['-parameters']).pa
+        assert cli.parse(['-pa']).parameters
+
+        assert cli.parse(['--indy']).i
+        assert cli.parse(['-indy']).i
+        assert cli.parse(['-i']).indy
+
+        assert cli.parse(['--help']).h
+        assert cli.parse(['-help']).h
+        assert cli.parse(['-h']).help
+
+        assert cli.parse(['--version']).V
+        assert cli.parse(['-version']).V
+        assert cli.parse(['-V']).version
+
+        assert cli.parse('--configscript abc'.split()).configscript == 'abc'
+        assert cli.parse( '-configscript abc'.split()).configscript == 'abc'
+    }
+
+    public void testAcceptLongOptionsWithSingleHyphen_usage() {
+        resetPrintWriter()
+        CliBuilder cli = new CliBuilder(acceptLongOptionsWithSingleHyphen: true, writer: printWriter)
+        cli.with {
+            cp(longOpt: 'classpath', 'cli.option.cp.description')
+            h(longOpt: 'help', 'cli.option.help.description')
+            V(longOpt: 'version', 'cli.option.version.description')
+            pa(longOpt: 'parameters', 'cli.option.parameters.description')
+            i(longOpt: 'indy', 'cli.option.indy.description')
+            D(longOpt: 'define', args: 2, argName: 'String', valueSeparator: '=', 'cli.option.define.description')
+            _(longOpt: 'configscript', args: 1, 'cli.option.configscript.description')
+        }
+        cli.usage()
+        def expectedUsage = """\
+Usage: groovy [-hiV] [-cp] [-pa] [-configscript=PARAM] [-D=<String>=<String>]...
+      -configscript, --configscript=PARAM
+                            cli.option.configscript.description
+      -cp, -classpath, --classpath
+                            cli.option.cp.description
+  -D, -define, --define=<String>=<String>
+                            cli.option.define.description
+  -h, -help, --help         cli.option.help.description
+  -i, -indy, --indy         cli.option.indy.description
+      -pa, -parameters, --parameters
+                            cli.option.parameters.description
+  -V, -version, --version   cli.option.version.description"""
+        groovy.util.GroovyTestCase.assertEquals(expectedUsage, stringWriter.toString().tokenize('\r\n').join('\n'))
+
+        resetPrintWriter()
+        cli = new CliBuilder(acceptLongOptionsWithSingleHyphen: false, writer: printWriter)
+        cli.with {
+            cp(longOpt: 'classpath', 'cli.option.cp.description')
+            h(longOpt: 'help', 'cli.option.help.description')
+            V(longOpt: 'version', 'cli.option.version.description')
+            pa(longOpt: 'parameters', 'cli.option.parameters.description')
+            i(longOpt: 'indy', 'cli.option.indy.description')
+            D(longOpt: 'define', args: 2, argName: 'String', valueSeparator: '=', 'cli.option.define.description')
+            _(longOpt: 'configscript', args: 1, 'cli.option.configscript.description')
+        }
+        cli.usage()
+        expectedUsage = """\
+Usage: groovy [-hiV] [-cp] [-pa] [--configscript=PARAM]
+              [-D=<String>=<String>]...
+      --configscript=PARAM   cli.option.configscript.description
+      -cp, --classpath       cli.option.cp.description
+  -D, --define=<String>=<String>
+                             cli.option.define.description
+  -h, --help                 cli.option.help.description
+  -i, --indy                 cli.option.indy.description
+      -pa, --parameters      cli.option.parameters.description
+  -V, --version              cli.option.version.description"""
+        groovy.util.GroovyTestCase.assertEquals(expectedUsage, stringWriter.toString().tokenize('\r\n').join('\n'))
+    }
+}

http://git-wip-us.apache.org/repos/asf/groovy/blob/0803166c/subprojects/groovy-docgenerator/build.gradle
----------------------------------------------------------------------
diff --git a/subprojects/groovy-docgenerator/build.gradle b/subprojects/groovy-docgenerator/build.gradle
index 77d93f2..40b7da5 100644
--- a/subprojects/groovy-docgenerator/build.gradle
+++ b/subprojects/groovy-docgenerator/build.gradle
@@ -19,6 +19,7 @@
 dependencies {
     compile rootProject
     compile project(':groovy-cli-commons')
+    compile project(':groovy-cli-picocli')
     compile project(':groovy-templates')
     testCompile project(':groovy-test')
     compile "com.thoughtworks.qdox:qdox:$qdoxVersion"

http://git-wip-us.apache.org/repos/asf/groovy/blob/0803166c/subprojects/groovy-groovydoc/build.gradle
----------------------------------------------------------------------
diff --git a/subprojects/groovy-groovydoc/build.gradle b/subprojects/groovy-groovydoc/build.gradle
index 3e86d0b..8c500f3 100644
--- a/subprojects/groovy-groovydoc/build.gradle
+++ b/subprojects/groovy-groovydoc/build.gradle
@@ -20,6 +20,7 @@ dependencies {
     compile rootProject
     testCompile rootProject.sourceSets.test.runtimeClasspath
     compile project(':groovy-cli-commons')
+    compile project(':groovy-cli-picocli')
     compile project(':groovy-templates')
     runtime project(':groovy-dateutil')
     testCompile project(':groovy-test')


[2/2] groovy git commit: GROOVY-8520 add picocli-based CliBuilder (closes #688)

Posted by pa...@apache.org.
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 <re...@yahoo.com>
Authored: Sat Apr 21 01:01:02 2018 +0200
Committer: Paul King <pa...@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[]
+
+    }
+}