diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..ca48ad73 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.DS_Store +.gradle +*.iml +build +.idea +gradlew.bat diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 00000000..4ea6b4ea --- /dev/null +++ b/.travis.yml @@ -0,0 +1,22 @@ +language: java +dist: trusty +sudo: true +before_cache: +- rm -f $HOME/.gradle/caches/modules-2/modules-2.lock +cache: + directories: + - $HOME/.gradle/caches/ + - $HOME/.gradle/wrapper/ + - $HOME/.m2 +jdk: +- oraclejdk8 +script: ./gradlew jacocoTestReport ; +env: + matrix: + - ARTIFACTORY_USERNAME=gatkci + global: + secure: rF9xJaWR1LRF6y0Ujq+zvfg2wO0DRgs1vqoUgS9BoHuXKRDNWyiEYbX0Mxef5LxXgqHSoIxirIBnjGxpDYaM+kRwNm1IqcW/C5Z5slKY12lbwFgTFdROfKS4lGMVo6U5/w+hyknrEVxEV4ULw7I2Z4sUWHU0X+uOhf7JP2sYTXcl0kyUPP4crSAMGQ+J/Epc4mvmxuNaSCbAq74+JW+GJ8KqbEmrDPRBpDFAoeISjmnmXGlvPECgIuPjFp3pJ3nOv3hDJqIb6jWs8Jt2w4xeByg4ENPI2z+oAzWM7QqqyybK706LMy/jppNqVa3AOUCCiQIjTnJYgapAthIatfSrJrtwKxmrxyq1v2XfPuXqeMi3PB8Yz/ikOWk2dWU/XQnqV9u1ZEuFwcs+0InsDVVYkLQ3A7RJ570CdDYsqejzdGDk25r+BIxob8TViCneG1UzWydKd3XFmtaxLORMWqu0vyoQ6OM+w7Yc9x1fJm+yW458UplKdQW9yyxcMl9uFMcn/shwTPlPDIN/ktVsF1Q8bA7fGl9WEoxRtpXZYtNhhTkI9zTLMGjU8JZhTfV6QGmEbtGs1+uD2HAFWueuoRiHGlH+uqIapjCK81hWUcIwY6Q1B0bvVnjw58ifst/irB4pP82SzC0KQXxNzjBVLpU275/inhmA9/ZTji/7wHFgqIk= +after_success: +- echo "TRAVIS_BRANCH='$TRAVIS_BRANCH'"; echo "JAVA_HOME='$JAVA_HOME'"; +- bash <(curl -s https://codecov.io/bash); if [ "$TRAVIS_BRANCH" == "master" ]; then + ./gradlew uploadArchives; fi diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 00000000..47f4c9df --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,26 @@ +Copyright (c) 2009-2016, GATK Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +* Neither the name Broad Institute, Inc. nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE diff --git a/README.md b/README.md index b5d224ff..7b2edef8 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,11 @@ -# barclay -Command line argument parser and online documentation generation utilities for java command line programs. +[![License (3-Clause BSD)](https://img.shields.io/badge/license-BSD%203--Clause-blue.svg)](https://opensource.org/licenses/BSD-3-Clause) + + +# Barclay (Under Construction) +Barclay is a set of classes for annotating, parsing, validating, and generating documentation for command line options. + +##Requirements +* Java 8 +* Gradle 3.1 or greater. We recommend using the `./gradlew` script which will + download and use an appropriate gradle version automatically. + diff --git a/build.gradle b/build.gradle new file mode 100644 index 00000000..38a47911 --- /dev/null +++ b/build.gradle @@ -0,0 +1,172 @@ + +plugins { + id "java" + id 'maven' + id 'signing' + id 'jacoco' + id 'com.palantir.git-version' version '0.5.1' //version helper +} + +sourceCompatibility = 1.8 +targetCompatibility = 1.8 + +group = 'org.broadinstitute' + +final isRelease = Boolean.getBoolean("release") +version = (isRelease ? gitVersion() : gitVersion() + "-SNAPSHOT").replaceAll(".dirty", "") + +repositories { + mavenCentral() + maven { + url "https://artifactory.broadinstitute.org/artifactory/libs-snapshot/" //for Broad snapshots + } + + mavenLocal() +} + +jacocoTestReport { + dependsOn test + group = "Reporting" + description = "Generate Jacoco coverage reports after running tests." + additionalSourceDirs = files(sourceSets.main.allJava.srcDirs) + + reports { + xml.enabled = true // codecov plugin depends on xml format report + html.enabled = true + } +} + +compileJava { + options.compilerArgs = ['-proc:none', '-Xlint:all','-Werror','-Xdiags:verbose'] +} +compileTestJava { + options.compilerArgs = ['-proc:none', '-Xlint:all','-Werror','-Xdiags:verbose'] +} + +dependencies { + compile 'net.sf.jopt-simple:jopt-simple:5.0.3' + + compile 'org.apache.commons:commons-lang3:3.4' + compile 'org.apache.logging.log4j:log4j-api:2.3' + compile 'org.apache.logging.log4j:log4j-core:2.3' + + testCompile 'org.testng:testng:6.9.6' +} + +test { + useTestNG() + outputs.upToDateWhen { false } //tests will never be "up to date" so you can always rerun them + + // show standard out and standard error of the test JVM(s) on the console + testLogging.showStandardStreams = true + beforeTest { descriptor -> + logger.lifecycle("Running Test: " + descriptor) + } + + // listen to standard out and standard error of the test JVM(s) + onOutput { descriptor, event -> + logger.lifecycle("Test: " + descriptor + " produced standard out/err: " + event.message ) + } + + testLogging { + testLogging { + events "skipped", "failed" + exceptionFormat = "full" + } + afterSuite { desc, result -> + if (!desc.parent) { // will match the outermost suite + println "Results: ${result.resultType} (${result.testCount} tests, ${result.successfulTestCount} successes, ${result.failedTestCount} failures, ${result.skippedTestCount} skipped)" + } + } + } + +} + +task javadocJar(type: Jar, dependsOn: javadoc) { + classifier = 'javadoc' + from 'build/docs/javadoc' +} + +task sourcesJar(type: Jar) { + from sourceSets.main.allSource + classifier = 'sources' +} + +// This is a hack to disable the java 8 default javadoc lint until we fix the html formatting +if (JavaVersion.current().isJava8Compatible()) { + tasks.withType(Javadoc) { + options.addStringOption('Xdoclint:none', '-quiet') + } +} + +/** + *This specifies what artifacts will be built and uploaded when performing a maven upload. + */ +artifacts { + archives jar + archives javadocJar + archives sourcesJar +} + +/** + * Sign non-snapshot releases with our secret key. This should never need to be invoked directly. + */ +signing { + required { isRelease && gradle.taskGraph.hasTask("uploadArchives") } + sign configurations.archives +} + +/** + * Upload a release to sonatype. You must be an authorized uploader and have your sonatype + * username and password information in your gradle properties file. See the readme for more info. + * + * For releasing to your local maven repo, use gradle install + */ +uploadArchives { + doFirst { + println "Attempting to upload version:$version" + } + repositories { + mavenDeployer { + beforeDeployment { MavenDeployment deployment -> signing.signPom(deployment) } + + repository(url: "https://oss.sonatype.org/service/local/staging/deploy/maven2/") { + authentication(userName: project.findProperty("sonatypeUsername"), password: project.findProperty("sonatypePassword")) + } + + snapshotRepository(url: "https://artifactory.broadinstitute.org/artifactory/libs-snapshot-local/") { + authentication(userName: System.env.ARTIFACTORY_USERNAME, password: System.env.ARTIFACTORY_PASSWORD) + } + + pom.project { + name 'Barclay' + packaging 'jar' + description 'Development on Barclay command line parsing and documentation utilities' + url 'http://github.com/broadinstitute/barclay' + + scm { + url 'scm:git@github.com:broadinstitute/barclay.git' + connection 'scm:git@github.com:broadinstitute/barclay.git' + developerConnection 'scm:git@github.com:broadinstitute/barclay.git' + } + + developers { + developer { + id = "gatkdev" + name = "GATK Development Team" + email = "gatk-dev-public@broadinstitute.org" + } + } + + licenses { + license { + name 'BSD 3-Clause' + url 'https://github.com/broadinstitute/barclay/blob/master/LICENSE.TXT' + distribution 'repo' + } + } + } + } + } +} + diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..6ffa2378 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..ebaa8e31 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Mon Nov 07 10:14:42 EST 2016 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-3.1-bin.zip diff --git a/gradlew b/gradlew new file mode 100755 index 00000000..9aa616c2 --- /dev/null +++ b/gradlew @@ -0,0 +1,169 @@ +#!/usr/bin/env bash + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn ( ) { + echo "$*" +} + +die ( ) { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules +function splitJvmOpts() { + JVM_OPTS=("$@") +} +eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS +JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME" + +# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong +if [[ "$(uname)" == "Darwin" ]] && [[ "$HOME" == "$PWD" ]]; then + cd "$(dirname "$0")" +fi + +exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@" diff --git a/src/main/java/org/broadinstitute/barclay/argparser/Advanced.java b/src/main/java/org/broadinstitute/barclay/argparser/Advanced.java new file mode 100644 index 00000000..0ac9393f --- /dev/null +++ b/src/main/java/org/broadinstitute/barclay/argparser/Advanced.java @@ -0,0 +1,13 @@ +package org.broadinstitute.barclay.argparser; + +import java.lang.annotation.*; + +/** + * Indicates that an argument is considered an advanced option. + */ +@Documented +@Inherited +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.FIELD}) +public @interface Advanced { +} diff --git a/src/main/java/org/broadinstitute/barclay/argparser/Argument.java b/src/main/java/org/broadinstitute/barclay/argparser/Argument.java new file mode 100644 index 00000000..eaa84e37 --- /dev/null +++ b/src/main/java/org/broadinstitute/barclay/argparser/Argument.java @@ -0,0 +1,105 @@ +package org.broadinstitute.barclay.argparser; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Used to annotate which fields of a CommandLineProgram are options given at the command line. + * If a command line call looks like "cmd -option foo -x y bar baz" the CommandLineProgram + * would have annotations on fields to handle the values of option and x. The java type of the option + * will be inferred from the type of the field or from the generic type of the collection + * if this option is allowed more than once. The type must be an enum or + * have a constructor with a single String parameter. + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.FIELD) +@Documented +public @interface Argument { + + /** + * The full name of the command-line argument. Full names should be + * prefixed on the command-line with a double dash (--). + * @return Selected full name, or "" to use the default. + */ + String fullName() default ""; + + /** + * Specified short name of the command. Short names should be prefixed + * with a single dash. Argument values can directly abut single-char + * short names or be separated from them by a space. + * @return Selected short name, or "" for none. + */ + String shortName() default ""; + + /** + * Documentation for the command-line argument. Should appear when the + * --help argument is specified. + * @return Doc string associated with this command-line argument. + */ + String doc() default "Undocumented option"; + + /** + * If set to false, a {@link org.broadinstitute.barclay.argparser.CommandLineException.MissingArgument} will be thrown + * if the option is not specified. + * If 2 options are mutually exclusive and both are required it will be interpreted as one or the other is required + * and an exception will only be thrown if neither are specified. + * An argument with a non-null default value specified will ignore this flag and always be treated as optional + */ + boolean optional() default false; + + /** + * Array of option names that cannot be used in conjunction with this one. + * If 2 options are mutually exclusive and both have optional=false it will be + * interpreted as one OR the other is required and an exception will only be thrown if + * neither are specified. + */ + String[] mutex() default {}; + + /** + * Is this an Option common to all command line programs. If it is then it will only + * be displayed in usage info when H or STDHELP is used to display usage. + */ + boolean common() default false; + + /** + * Does this option have special treatment in the argument parsing system. + * Some examples are arguments_file and help, which have special behavior in the parser. + * This is intended for documenting these options. + */ + boolean special() default false; + + /** + * Are the contents of this argument private and should be kept out of logs. + * Examples of sensitive arguments are encryption and api keys. + */ + boolean sensitive() default false; + + /** + * Overwrite default order in which Option are printed in usage by explicitly setting a + * print position e.g. printOrder=1 is printed before printOrder=2. + * Options without printOrder automatically receive a printOrder that (1) is a multiple of 1000 + * and (2) reflects the order's default position. This gives you the option to insert your own options between + * options inherited from super classes (which order you do not control). + * The default ordering follows (1)the option declaration position in the class and (2) sub-classes options printed + * before superclass options. + * + * @author charles girardot + */ + int printOrder() default Integer.MAX_VALUE; + + /** The minimum number of times that this option is required. */ + int minElements() default 0; + + /** The maximum number of times this option is allowed. */ + int maxElements() default Integer.MAX_VALUE; + + /** + * This boolean determines if this annotation overrides a parent annotation. If that is the case then + * the options of the parent annotation are overridden with this annotation. + */ + boolean overridable() default false; + +} diff --git a/src/main/java/org/broadinstitute/barclay/argparser/ArgumentCollection.java b/src/main/java/org/broadinstitute/barclay/argparser/ArgumentCollection.java new file mode 100644 index 00000000..bf1576b2 --- /dev/null +++ b/src/main/java/org/broadinstitute/barclay/argparser/ArgumentCollection.java @@ -0,0 +1,16 @@ +package org.broadinstitute.barclay.argparser; + +import java.lang.annotation.*; + +/** + * Used to annotate a field in a CommandLineProgram that holds an instance containing @Argument-annotated + * fields. + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.FIELD) +@Documented +@Inherited +public @interface ArgumentCollection { + /** Text that appears for this group of options in text describing usage of the command line program. */ + String doc() default ""; +} diff --git a/src/main/java/org/broadinstitute/barclay/argparser/ClassFinder.java b/src/main/java/org/broadinstitute/barclay/argparser/ClassFinder.java new file mode 100644 index 00000000..ef386265 --- /dev/null +++ b/src/main/java/org/broadinstitute/barclay/argparser/ClassFinder.java @@ -0,0 +1,188 @@ +package org.broadinstitute.barclay.argparser; + +import org.apache.logging.log4j.Logger; +import org.apache.logging.log4j.LogManager; + +import java.io.File; +import java.io.IOException; +import java.lang.reflect.Modifier; +import java.net.URL; +import java.net.URLClassLoader; +import java.net.URLDecoder; +import java.util.*; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; + +/** + * Utility class that can scan for classes in the classpath and find all the ones + * annotated with a particular annotation. + * + * @author Tim Fennell + */ +public final class ClassFinder { + private final Set> classes = new LinkedHashSet<>(); + private final ClassLoader loader; + private Class parentType; + // If not null, only look for classes in this jar + private String jarPath = null; + + private static final Logger log = LogManager.getLogger(); + + public ClassFinder() { + loader = Thread.currentThread().getContextClassLoader(); + } + + public ClassFinder(final ClassLoader loader) { + this.loader = loader; + } + + public ClassFinder(final File jarFile) throws IOException { + // The class loader must have the context in order to load dependent classes when loading a class, + // but the jarPath is remembered so that the iteration over the classpath skips anything other than + // the jarPath. + jarPath = jarFile.getCanonicalPath(); + final URL[] urls = {new URL("file", "", jarPath)}; + loader = new URLClassLoader(urls, Thread.currentThread().getContextClassLoader()); + } + + /** Convert a filename to a class name by removing '.class' and converting '/'s to '.'s. */ + public String toClassName(final String filename) { + return filename.substring(0, filename.lastIndexOf(".class")) + .replace('/', '.').replace('\\', '.'); + } + + /** + * Scans the classpath for classes within the specified package and sub-packages that + * extend the parentType. This method can be called repeatedly + * with different packages. Classes are accumulated internally and + * can be accessed by calling {@link #getClasses()}. + */ + public void find(String packageName, final Class parentType) { + this.parentType = parentType; + packageName = packageName.replace('.', '/'); + final Enumeration urls; + + try { + urls = loader.getResources(packageName); + } + catch (IOException ioe) { + log.warn("Could not read package: " + packageName, ioe); + return; + } + + while (urls.hasMoreElements()) { + try { + String urlPath = urls.nextElement().getFile(); + urlPath = URLDecoder.decode(urlPath, "UTF-8"); + if ( urlPath.startsWith("file:") ) { + urlPath = urlPath.substring(5); + } + if (urlPath.indexOf('!') > 0) { + urlPath = urlPath.substring(0, urlPath.indexOf('!')); + } + if (jarPath != null && !jarPath.equals(urlPath)) { + continue; + } + + //Log.info("Looking for classes in location: " + urlPath); + final File file = new File(urlPath); + if ( file.isDirectory() ) { + scanDir(file, packageName); + } + else { + scanJar(file, packageName); + } + } + catch (IOException ioe) { + log.warn("could not read entries", ioe); + } + } + } + + /** + * Scans the entries in a ZIP/JAR file for classes under the parent package. + * @param file the jar file to be scanned + * @param packagePath the top level package to start from + */ + protected void scanJar(final File file, final String packagePath) throws IOException { + final ZipFile zip = new ZipFile(file); + final Enumeration entries = zip.entries(); + while ( entries.hasMoreElements() ) { + final ZipEntry entry = entries.nextElement(); + final String name = entry.getName(); + if (name.startsWith(packagePath)) { + handleItem(name); + } + } + } + + /** + * Scans a directory on the filesystem for classes. + * @param file the directory or file to examine + * @param path the package path acculmulated so far (e.g. edu/mit/broad) + */ + protected void scanDir(final File file, final String path) { + for ( final File child: file.listFiles() ) { + final String newPath = (path==null ? child.getName() : path + '/' + child.getName() ); + if ( child.isDirectory() ) { + scanDir(child, newPath); + } + else { + handleItem(newPath); + } + } + } + + /** + * Checks an item to see if it is a class and is annotated with the specified + * annotation. If so, adds it to the set, otherwise ignores it. + * @param name the path equivelant to the package + class/item name + */ + protected void handleItem(final String name) { + if (name.endsWith(".class")) { + final String classname = toClassName(name); + + try { + final Class type = loader.loadClass(classname); + if (parentType.isAssignableFrom(type)) { + this.classes.add(type); + } + } + catch (Throwable t) { + log.debug("could not load class: " + classname, t); + } + } + } + + /** Fetches the set of classes discovered so far. */ + public Set> getClasses() { + return this.classes; + } + + /** + * Fetches the set of classes discovered so far, subsetted down to concrete (non-abstract/interface) classes only + * + * @return subset of classes discovered so far including only concrete (non-abstract/interface) classes + */ + public Set> getConcreteClasses() { + Set> concreteClassSet = new LinkedHashSet<>(); + + for ( Class clazz : classes ) { + if ( isConcrete(clazz) ) { + concreteClassSet.add(clazz); + } + } + + return concreteClassSet; + } + + /** + * Determines whether or not the specified class is concrete (ie., non-abstract and non-interface) + * + * @param clazz class to check + * @return true if the class is neither abstract nor an interface, otherwise false + */ + public static boolean isConcrete( final Class clazz ) { + return ! Modifier.isAbstract(clazz.getModifiers()) && ! Modifier.isInterface(clazz.getModifiers()); + } +} \ No newline at end of file diff --git a/src/main/java/org/broadinstitute/barclay/argparser/CommandLineArgumentParser.java b/src/main/java/org/broadinstitute/barclay/argparser/CommandLineArgumentParser.java new file mode 100644 index 00000000..2ed3022e --- /dev/null +++ b/src/main/java/org/broadinstitute/barclay/argparser/CommandLineArgumentParser.java @@ -0,0 +1,1119 @@ +package org.broadinstitute.barclay.argparser; + +import joptsimple.OptionException; +import joptsimple.OptionParser; +import joptsimple.OptionSet; +import joptsimple.OptionSpec; +import joptsimple.OptionSpecBuilder; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.text.WordUtils; + +import org.broadinstitute.barclay.utils.Utils; + +import java.io.BufferedReader; +import java.io.FileReader; +import java.io.IOException; +import java.io.PrintStream; +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.InvocationTargetException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Comparator; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/** + * Annotation-driven utility for parsing command-line arguments, checking for errors, and producing usage message. + *

+ * This class supports arguments of the form -KEY VALUE, plus positional arguments. + *

+ * The caller must supply an object that both defines the command line and has the parsed arguments set into it. + * For each possible "-KEY VALUE" argument, there must be a public data member annotated with @Argument. The KEY name is + * the name of the fullName attribute of @Argument. An abbreviated name may also be specified with the shortName attribute + * of @Argument. + * If the data member is a List, then the argument may be specified multiple times. The type of the data member, + * or the type of the List element must either have a ctor T(String), or must be an Enum. List arguments must + * be initialized by the caller with some kind of list. Any other argument that is non-null is assumed to have the given + * value as a default. If an argument has no default value, and does not have the optional attribute of @Argument set, + * is required. For List arguments, minimum and maximum number of elements may be specified in the @Argument annotation. + *

+ * A single List data member may be annotated with the @PositionalArguments. This behaves similarly to a Argument + * with List data member: the caller must initialize the data member, the type must be constructable from String, and + * min and max number of elements may be specified. If no @PositionalArguments annotation appears in the object, + * then it is an error for the command line to contain positional arguments. + *

+ * A single String public data member may be annotated with @Usage. This string, if present, is used to + * construct the usage message. Details about the possible arguments are automatically appended to this string. + * If @Usage does not appear, a boilerplate usage message is used. + */ +public final class CommandLineArgumentParser implements CommandLineParser { + // For formatting argument section of usage message. + private static final int ARGUMENT_COLUMN_WIDTH = 30; + private static final int DESCRIPTION_COLUMN_WIDTH = 90; + + private static final String ENUM_OPTION_DOC_PREFIX = "Possible values: {"; + private static final String ENUM_OPTION_DOC_SUFFIX = "} "; + + // Use these if no @Usage annotation + private static final String defaultUsagePreamble = "Usage: program [arguments...]\n"; + private static final String defaultUsagePreambleWithPositionalArguments = + "Usage: program [arguments...] [positional-arguments...]\n"; + private static final String NULL_STRING = "null"; + public static final String COMMENT = "#"; + public static final String POSITIONAL_ARGUMENTS_NAME = "Positional Argument"; + + // Map from (full class) name of each CommandLinePluginDescriptor requested and + // found to the actual descriptor instance + private Map> pluginDescriptors = new HashMap<>(); + + // Return the plugin instance corresponding to the targetDescriptor class + @Override + public T getPluginDescriptor(Class targetDescriptor) { + return targetDescriptor.cast(pluginDescriptors.get(targetDescriptor.getName())); + } + + private final Set argumentsFilesLoadedAlready = new LinkedHashSet<>(); + + /** + * A typical command line program will call this to get the beginning of the usage message, + * and then append a description of the program, like this: + * commandLineParser.getStandardUsagePreamble(getClass()) + "Frobnicates the freebozzle." + */ + @Override + public String getStandardUsagePreamble(final Class mainClass) { + return "USAGE: " + mainClass.getSimpleName() + " [arguments]\n\n"; + } + + + private void putInArgumentMap(ArgumentDefinition arg){ + for (String key: arg.getNames()){ + argumentMap.put(key, arg); + } + } + + private boolean inArgumentMap(ArgumentDefinition arg){ + for (String key: arg.getNames()){ + if(argumentMap.containsKey(key)){ + return true; + } + } + return false; + } + + // This is the object that the caller has provided that contains annotations, + // and into which the values will be assigned. + private final Object callerArguments; + + + // null if no @PositionalArguments annotation + private Field positionalArguments; + private int minPositionalArguments; + private int maxPositionalArguments; + private Object positionalArgumentsParent; + + // List of all the data members with @Argument annotation + private List argumentDefinitions = new ArrayList<>(); + + // Maps long name, and short name, if present, to an argument definition that is + // also in the argumentDefinitions list. + private final Map argumentMap = new LinkedHashMap<>(); + + // In case implementation wants to get at arg for some reason. + private String[] argv; + + + // The associated program properties using the CommandLineProgramProperties annotation + private final CommandLineProgramProperties programProperties; + + private String getUsagePreamble() { + String usagePreamble = ""; + if (null != programProperties) { + usagePreamble += programProperties.summary(); + } else if (positionalArguments == null) { + usagePreamble += defaultUsagePreamble; + } else { + usagePreamble += defaultUsagePreambleWithPositionalArguments; + } + return usagePreamble; + } + + /** + * @param callerArguments The object containing the command line arguments to be populated by + * this command line parser. + */ + public CommandLineArgumentParser(final Object callerArguments) { + this(callerArguments, new ArrayList<>()); + } + + /** + * @param callerArguments The object containing the command line arguments to be populated by + * this command line parser. + * @param pluginDescriptors A list of {@link CommandLinePluginDescriptor} objects that + * should be used by this command line parser to extend the list of + * command line arguments with dynamically discovered plugins. If + * null, no descriptors are loaded. + */ + public CommandLineArgumentParser( + final Object callerArguments, + final List> pluginDescriptors) { + Utils.nonNull(callerArguments, "The object with command line arguments cannot be null"); + Utils.nonNull(pluginDescriptors, "The list of pluginDescriptors cannot be null"); + + this.callerArguments = callerArguments; + + createArgumentDefinitions(callerArguments, null); + createCommandLinePluginArgumentDefinitions(pluginDescriptors); + + this.programProperties = this.callerArguments.getClass().getAnnotation(CommandLineProgramProperties.class); + } + + private void createArgumentDefinitions( + final Object callerArguments, + final CommandLinePluginDescriptor controllingDescriptor) { + for (final Field field : CommandLineParser.getAllFields(callerArguments.getClass())) { + if (field.getAnnotation(Argument.class) != null && field.getAnnotation(ArgumentCollection.class) != null){ + throw new CommandLineException.CommandLineParserInternalException("An Argument cannot be an argument collection: " + +field.getName() + " in " + callerArguments.toString() + " is annotated as both."); + } + if (field.getAnnotation(PositionalArguments.class) != null) { + handlePositionalArgumentAnnotation(field, callerArguments); + } + if (field.getAnnotation(Argument.class) != null) { + handleArgumentAnnotation(field, callerArguments, controllingDescriptor); + } + if (field.getAnnotation(ArgumentCollection.class) != null) { + try { + field.setAccessible(true); + createArgumentDefinitions(field.get(callerArguments), controllingDescriptor); + } catch (final IllegalAccessException e) { + throw new CommandLineException.ShouldNeverReachHereException("should never reach here because we setAccessible(true)", e); + } + } + } + } + + // Find all the instances of plugins specified by the provided plugin descriptors + private void createCommandLinePluginArgumentDefinitions( + final List> requestedPluginDescriptors) { + // For each descriptor, create the argument definitions for the descriptor object itself, + // then process it's plugin classes + requestedPluginDescriptors.forEach( + descriptor -> { + pluginDescriptors.put(descriptor.getClass().getName(), descriptor); + createArgumentDefinitions(descriptor, null); + findPluginsForDescriptor(descriptor); + } + ); + } + + // Find all of the classes that derive from the class specified by the descriptor, obtain an + // instance each and add its ArgumentDefinitions + private void findPluginsForDescriptor( + final CommandLinePluginDescriptor pluginDescriptor) { + final ClassFinder classFinder = new ClassFinder(); + pluginDescriptor.getPackageNames().forEach( + pkg -> classFinder.find(pkg, pluginDescriptor.getPluginClass())); + final Set> pluginClasses = classFinder.getClasses(); + + final List plugins = new ArrayList<>(pluginClasses.size()); + for (Class c : pluginClasses) { + if (pluginDescriptor.getClassFilter().test(c)) { + try { + final Object plugin = pluginDescriptor.getInstance(c); + plugins.add(plugin); + createArgumentDefinitions(plugin, pluginDescriptor); + } catch (InstantiationException | IllegalAccessException e) { + throw new CommandLineException.CommandLineParserInternalException("Problem making an instance of plugin " + c + + " Do check that the class has a non-arg constructor", e); + } + } + } + } + + @Override + public String getVersion() { + return "Version:" + this.callerArguments.getClass().getPackage().getImplementationVersion(); + } + + /** + * Print a usage message based on the arguments object passed to the ctor. + * + * @param stream Where to write the usage message. + * @param printCommon True if common args should be included in the usage message. + */ + @Override + public void usage(final PrintStream stream, final boolean printCommon) { + stream.print(getStandardUsagePreamble(callerArguments.getClass()) + getUsagePreamble()); + stream.println("\n" + getVersion()); + + // filter on common and partition on plugin-controlled + final Map> allArgsMap = argumentDefinitions.stream() + .filter(argumentDefinition -> printCommon || !argumentDefinition.isCommon) + .collect(Collectors.partitioningBy(a -> a.controllingDescriptor == null)); + + List nonPluginArgs = allArgsMap.get(true); + if (null != nonPluginArgs && !nonPluginArgs.isEmpty()) { + // partition the non-plugin args on optional + final Map> unconditionalArgsMap = nonPluginArgs.stream() + .collect(Collectors.partitioningBy(a -> a.optional)); + + final List reqArgs = unconditionalArgsMap.get(false); // required args + if (reqArgs != null && !reqArgs.isEmpty()) { + stream.println("\n\nRequired Arguments:\n"); + reqArgs.sort(ArgumentDefinition.sortByLongName); + reqArgs.stream().forEach(argumentDefinition -> printArgumentUsage(stream, argumentDefinition)); + } + + final List optArgs = unconditionalArgsMap.get(true); // optional args + if (optArgs != null && !optArgs.isEmpty()) { + stream.println("\nOptional Arguments:\n"); + optArgs.sort(ArgumentDefinition.sortByLongName); + optArgs.stream().forEach(argumentDefinition -> printArgumentUsage(stream, argumentDefinition)); + } + } + + // now the conditional/dependent args (those controlled by a plugin descriptor) + List conditionalArgs = allArgsMap.get(false); + if (null != conditionalArgs && !conditionalArgs.isEmpty()) { + // group all of the conditional argdefs by the name of their controlling pluginDescriptor class + final Map, List> argsByControllingDescriptor = + conditionalArgs + .stream() + .collect(Collectors.groupingBy(argDef -> argDef.controllingDescriptor)); + + // sort the list of controlling pluginDescriptors by display name and iterate through them + final List> pluginDescriptorSortedByName = + new ArrayList<>(argsByControllingDescriptor.keySet()); + pluginDescriptorSortedByName.sort( + (a, b) -> String.CASE_INSENSITIVE_ORDER.compare(a.getDisplayName(), b.getDisplayName()) + ); + for (final CommandLinePluginDescriptor descriptor: pluginDescriptorSortedByName) { + stream.println("Conditional Arguments for " + descriptor.getDisplayName() + ":\n"); + // get all the argument definitions controlled by this pluginDescriptor's plugins, group + // those by plugin, and get the sorted list of names of the owning plugins + final Map> byPlugin = + argsByControllingDescriptor.get(descriptor) + .stream() + .collect(Collectors.groupingBy(argDef -> argDef.parent.getClass().getSimpleName())); + final List sortedPluginNames = new ArrayList<>(byPlugin.keySet()); + sortedPluginNames.sort(String.CASE_INSENSITIVE_ORDER); + + // iterate over the owning plugins in sorted order, get each one's argdefs in sorted order, + // and print their usage + for (final String pluginName: sortedPluginNames) { + stream.println("Valid only if \"" + pluginName + "\" is specified:"); + final List pluginArgs = byPlugin.get(pluginName); + pluginArgs.sort(ArgumentDefinition.sortByLongName); + pluginArgs.forEach(argDef -> printArgumentUsage(stream, argDef)); + } + } + } + } + + /** + * Parse command-line arguments, and store values in callerArguments object passed to ctor. + * @param messageStream Where to write error messages. + * @param args Command line tokens. + * @return true if command line is valid and the program should run, false if help or version was requested + * @throws CommandLineException if there is an invalid command line + */ + @SuppressWarnings("unchecked") + @Override + public boolean parseArguments(final PrintStream messageStream, final String[] args) { + this.argv = args; + + OptionParser parser = new OptionParser(false); + + for (ArgumentDefinition arg : argumentDefinitions){ + OptionSpecBuilder bld = parser.acceptsAll(arg.getNames(), arg.doc); + if (arg.isFlag()) { + bld.withOptionalArg().withValuesConvertedBy(new StrictBooleanConverter()); + } else { + bld.withRequiredArg(); + } + } + if(positionalArguments != null){ + parser.nonOptions(); + } + + OptionSet parsedArguments; + try { + parsedArguments = parser.parse(args); + } catch (final OptionException e) { + throw new CommandLineException(e.getMessage()); + } + //Check for the special arguments file flag + //if it's seen, read arguments from that file and recursively call parseArguments() + if (parsedArguments.has(SpecialArgumentsCollection.ARGUMENTS_FILE_FULLNAME)) { + List argfiles = parsedArguments.valuesOf(SpecialArgumentsCollection.ARGUMENTS_FILE_FULLNAME).stream() + .map(f -> (String)f) + .collect(Collectors.toList()); + + List newargs = argfiles.stream() + .distinct() + .filter(file -> !argumentsFilesLoadedAlready.contains(file)) + .flatMap(file -> loadArgumentsFile(file).stream()) + .collect(Collectors.toList()); + argumentsFilesLoadedAlready.addAll(argfiles); + + if (!newargs.isEmpty()) { + newargs.addAll(Arrays.asList(args)); + return parseArguments(messageStream, newargs.toArray(new String[newargs.size()])); + } + } + + //check if special short circuiting arguments are set + if (isSpecialFlagSet(parsedArguments, SpecialArgumentsCollection.HELP_FULLNAME)) { + usage(messageStream, true); + return false; + } else if (isSpecialFlagSet(parsedArguments, SpecialArgumentsCollection.VERSION_FULLNAME)) { + messageStream.println(getVersion()); + return false; + } + + for (OptionSpec optSpec : parsedArguments.asMap().keySet()) { + if (parsedArguments.has(optSpec)) { + ArgumentDefinition argDef = argumentMap.get(optSpec.options().get(0)); + setArgument(argDef, (List) optSpec.values(parsedArguments)); + } + } + + for (Object arg : parsedArguments.nonOptionArguments()) { + setPositionalArgument((String) arg); + } + + assertArgumentsAreValid(); + + return true; + } + + /** + * helper to deal with the case of special flags that are evaluated before the options are properly set + */ + private boolean isSpecialFlagSet(OptionSet parsedArguments, String flagName){ + if (parsedArguments.has(flagName)){ + Object value = parsedArguments.valueOf(flagName); + return (value == null || !value.equals("false")); + } else{ + return false; + } + + } + + /** + * After command line has been parsed, make sure that all required arguments have values, and that + * lists with minimum # of elements have sufficient values. + * + * @throws CommandLineException if arguments requirements are not satisfied. + */ + private void assertArgumentsAreValid() { + validatePluginArguments(); // trim the list of plugin-derived argument definitions before validation + try { + for (final ArgumentDefinition argumentDefinition : argumentDefinitions) { + final String fullName = argumentDefinition.getLongName(); + final StringBuilder mutextArgumentNames = new StringBuilder(); + for (final String mutexArgument : argumentDefinition.mutuallyExclusive) { + final ArgumentDefinition mutextArgumentDef = argumentMap.get(mutexArgument); + if (mutextArgumentDef != null && mutextArgumentDef.hasBeenSet) { + mutextArgumentNames.append(" ").append(mutextArgumentDef.getLongName()); + } + } + if (argumentDefinition.hasBeenSet && mutextArgumentNames.length() > 0) { + throw new CommandLineException("Argument '" + fullName + + "' cannot be used in conjunction with argument(s)" + + mutextArgumentNames.toString()); + } + if (argumentDefinition.isCollection && !argumentDefinition.optional) { + @SuppressWarnings("rawtypes") + final Collection c = (Collection) argumentDefinition.getFieldValue(); + if (c.isEmpty()) { + throw new CommandLineException.MissingArgument(fullName, "Argument '" + fullName + "' must be specified at least once."); + } + } else if (!argumentDefinition.optional && !argumentDefinition.hasBeenSet && mutextArgumentNames.length() == 0) { + throw new CommandLineException.MissingArgument(fullName, "Argument '" + fullName + "' is required" + + (argumentDefinition.mutuallyExclusive.isEmpty() ? "." : " unless any of " + argumentDefinition.mutuallyExclusive + + " are specified.")); + } + } + if (positionalArguments != null) { + @SuppressWarnings("rawtypes") + final Collection c = (Collection) positionalArguments.get(positionalArgumentsParent); + if (c.size() < minPositionalArguments) { + throw new CommandLineException.MissingArgument(POSITIONAL_ARGUMENTS_NAME,"At least " + minPositionalArguments + + " positional arguments must be specified."); + } + } + } catch (final IllegalAccessException e) { + throw new CommandLineException.ShouldNeverReachHereException("Should never happen",e); + } + + } + + // Once all command line args have been processed, go through the argument definitions and + // validate any that are plugin class arguments against the controlling descriptor, trimming + // the list of argument definitions along the way by removing any that have not been set + // (so validation doesn't complain about missing required arguments for plugins that weren't + // specified) and throwing for any that have been set but are not allowed. Note that we don't trim + // the list of plugins themselves (just the argument definitions), since the plugin may contain + // other arguments that require validation. + private void validatePluginArguments() { + final List actualArgumentDefinitions = new ArrayList<>(); + for (final ArgumentDefinition argumentDefinition : argumentDefinitions) { + if (!argumentDefinition.isControlledByPlugin()) { + actualArgumentDefinitions.add(argumentDefinition); + } else { + final boolean isAllowed = argumentDefinition.controllingDescriptor.isDependentArgumentAllowed( + argumentDefinition.parent.getClass()); + if (argumentDefinition.hasBeenSet) { + if (!isAllowed) { + // dangling dependent argument; a value was specified but it's containing + // (predecessor) plugin argument wasn't specified + throw new CommandLineException( + String.format( + "Argument \"%s/%s\" is only valid when the argument \"%s\" is specified", + argumentDefinition.shortName, + argumentDefinition.getLongName(), + argumentDefinition.parent.getClass().getSimpleName())); + } + actualArgumentDefinitions.add(argumentDefinition); + } else if (isAllowed) { + // the predecessor argument was seen, so this value is allowed but hasn't been set; keep the + // argument definition to allow validation to check for missing required args + actualArgumentDefinitions.add(argumentDefinition); + } + } + } + + // update the list of argument definitions with the new list + argumentDefinitions = actualArgumentDefinitions; + + // finally, give each plugin a chance to trim down any unseen instances from it's own list + pluginDescriptors.entrySet().forEach(e -> e.getValue().validateArguments()); + } + + @SuppressWarnings("unchecked") + private void setPositionalArgument(final String stringValue) { + if (positionalArguments == null) { + throw new CommandLineException("Invalid argument '" + stringValue + "'."); + } + final Object value = constructFromString(CommandLineParser.getUnderlyingType(positionalArguments), stringValue, POSITIONAL_ARGUMENTS_NAME); + @SuppressWarnings("rawtypes") + final Collection c; + try { + c = (Collection) positionalArguments.get(callerArguments); + } catch (final IllegalAccessException e) { + throw new CommandLineException.ShouldNeverReachHereException(e); + } + if (c.size() >= maxPositionalArguments) { //we're checking if there is space to add another argument + throw new CommandLineException("No more than " + maxPositionalArguments + + " positional arguments may be specified on the command line."); + } + c.add(value); + } + + @SuppressWarnings("unchecked") + private void setArgument(ArgumentDefinition argumentDefinition, final List values) { + //special treatment for flags + if (argumentDefinition.isFlag() && values.isEmpty()){ + argumentDefinition.hasBeenSet = true; + argumentDefinition.setFieldValue(true); + return; + } + + if (!argumentDefinition.isCollection && (argumentDefinition.hasBeenSet || values.size() > 1)) { + throw new CommandLineException("Argument '" + argumentDefinition.getNames() + "' cannot be specified more than once."); + } + + for (String stringValue: values) { + final Object value; + if (stringValue.equals(NULL_STRING)) { + //"null" is a special value that allows the user to override any default + //value set for this arg + if (argumentDefinition.optional) { + value = null; + } else { + throw new CommandLineException("Non \"null\" value must be provided for '" + argumentDefinition.getNames() + "'."); + } + } else { + value = constructFromString(CommandLineParser.getUnderlyingType(argumentDefinition.field), stringValue, argumentDefinition.getLongName()); + } + + if (argumentDefinition.isCollection) { + @SuppressWarnings("rawtypes") + final Collection c = (Collection) argumentDefinition.getFieldValue(); + if (value == null) { + //user specified this arg=null which is interpreted as empty list + c.clear(); + } else { + c.add(value); + } + argumentDefinition.hasBeenSet = true; + } else { + argumentDefinition.setFieldValue(value); + argumentDefinition.hasBeenSet = true; + } + } + } + + /** + * Read an argument file and return a list of the args contained in it + * A line that starts with {@link #COMMENT} is ignored. + * + * @param argumentsFile a text file containing args + * @return false if a fatal error occurred + */ + private List loadArgumentsFile(final String argumentsFile) { + List args = new ArrayList<>(); + try (BufferedReader reader = new BufferedReader(new FileReader(argumentsFile))){ + String line; + while ((line = reader.readLine()) != null) { + if (!line.startsWith(COMMENT) && !line.trim().isEmpty()) { + args.addAll(Arrays.asList(StringUtils.split(line))); + } + } + } catch (final IOException e) { + throw new CommandLineException("I/O error loading arguments file:" + argumentsFile, e); + } + return args; + } + + private void printArgumentUsage(final PrintStream stream, final ArgumentDefinition argumentDefinition) { + printArgumentParamUsage(stream, argumentDefinition.getLongName(), argumentDefinition.shortName, + CommandLineParser.getUnderlyingType(argumentDefinition.field).getSimpleName(), + makeArgumentDescription(argumentDefinition)); + } + + + private void printArgumentParamUsage(final PrintStream stream, final String name, final String shortName, + final String type, final String argumentDescription) { + String argumentLabel = name; + if (type != null) argumentLabel = "--"+ argumentLabel; + + if (!shortName.isEmpty()) { + argumentLabel+=",-" + shortName; + } + argumentLabel += ":" + type; + stream.print(argumentLabel); + + int numSpaces = ARGUMENT_COLUMN_WIDTH - argumentLabel.length(); + if (argumentLabel.length() > ARGUMENT_COLUMN_WIDTH) { + stream.println(); + numSpaces = ARGUMENT_COLUMN_WIDTH; + } + printSpaces(stream, numSpaces); + final String wrappedDescription = WordUtils.wrap(argumentDescription, DESCRIPTION_COLUMN_WIDTH); + final String[] descriptionLines = wrappedDescription.split("\n"); + for (int i = 0; i < descriptionLines.length; ++i) { + if (i > 0) { + printSpaces(stream, ARGUMENT_COLUMN_WIDTH); + } + stream.println(descriptionLines[i]); + } + stream.println(); + } + + private String makeArgumentDescription(final ArgumentDefinition argumentDefinition) { + final StringBuilder sb = new StringBuilder(); + if (!argumentDefinition.doc.isEmpty()) { + sb.append(argumentDefinition.doc); + sb.append(" "); + } + if (argumentDefinition.isCollection) { + if (argumentDefinition.optional) { + sb.append("This argument may be specified 0 or more times. "); + } else { + sb.append("This argument must be specified at least once. "); + } + } + if (argumentDefinition.optional) { + sb.append("Default value: "); + sb.append(argumentDefinition.defaultValue); + sb.append(". "); + } else { + sb.append("Required. "); + } + // if this argument definition is a string field contained within a plugin descriptor (i.e., + // it holds the names of plugins specified by the user on the command line, such as read filter names), + // then we need to delegate to the plugin descriptor to generate the list of allowed values + if (CommandLinePluginDescriptor.class.isAssignableFrom(argumentDefinition.parent.getClass()) && + CommandLineParser.getUnderlyingType(argumentDefinition.field).equals(String.class)) { + usageForPluginDescriptorArgument(argumentDefinition, sb); + } else { + sb.append(getOptions(CommandLineParser.getUnderlyingType(argumentDefinition.field))); + } + if (!argumentDefinition.mutuallyExclusive.isEmpty()) { + sb.append(" Cannot be used in conjuction with argument(s)"); + for (final String argument : argumentDefinition.mutuallyExclusive) { + final ArgumentDefinition mutextArgumentDefinition = argumentMap.get(argument); + + if (mutextArgumentDefinition == null) { + throw new CommandLineException("Invalid argument definition in source code. " + argument + + " doesn't match any known argument."); + } + sb.append(" ").append(mutextArgumentDefinition.fieldName); + if (!mutextArgumentDefinition.shortName.isEmpty()) { + sb.append(" (").append(mutextArgumentDefinition.shortName).append(")"); + } + } + } + return sb.toString(); + } + + private void usageForPluginDescriptorArgument(final ArgumentDefinition argDef, final StringBuilder sb) { + final CommandLinePluginDescriptor descriptor = (CommandLinePluginDescriptor) argDef.parent; + // this argument came from a plugin descriptor; delegate to get the list of allowed values + final List allowedValues = new ArrayList<>(descriptor.getAllowedValuesForDescriptorArgument(argDef.getLongName())); + if (allowedValues.isEmpty()) { + sb.append("Any value allowed"); + } else { + allowedValues.sort(String.CASE_INSENSITIVE_ORDER); + sb.append("Possible Values: {"); + sb.append(String.join(", ", allowedValues)); + sb.append("}"); + } + } + + /** + * Generates the option help string for a {@code boolean} or {@link Boolean} typed argument. + * @return never {@code null}. + */ + private String getBooleanOptions() { + return String.format("%s%s, %s%s", ENUM_OPTION_DOC_PREFIX, Boolean.TRUE, Boolean.FALSE, ENUM_OPTION_DOC_SUFFIX); + } + + /** + * Composes the help string on the possible options an {@link Enum} typed argument can take. + * + * @param clazz target enum class. Assumed no to be {@code null}. + * @param enum class type. + * @param ClpEnum implementing version of <T>;. + * @throws CommandLineException if {@code <T>} has no constants. + * @return never {@code null}. + */ + private ,U extends Enum & ClpEnum> String getEnumOptions(final Class clazz) { + // We assume that clazz is guaranteed to be a Class, thus + // getEnumConstants() won't ever return a null. + final T[] enumConstants = clazz.getEnumConstants(); + if (enumConstants.length == 0) { + throw new CommandLineException(String.format("Bad argument enum type '%s' with no options", clazz.getName())); + } + + if (ClpEnum.class.isAssignableFrom(clazz)) { + @SuppressWarnings("unchecked") + final U[] clpEnumCastedConstants = (U[]) enumConstants; + return getEnumOptionsWithDescription(clpEnumCastedConstants); + } else { + return getEnumOptionsWithoutDescription(enumConstants); + } + } + + /** + * Composes the help string for enum options that do not provide additional help documentation. + * @param enumConstants the enum constants. Assumed non-null. + * @param the enum type. + * @return never {@code null}. + */ + private > String getEnumOptionsWithoutDescription(final T[] enumConstants) { + return Stream.of(enumConstants) + .map(T::name) + .collect(Collectors.joining(", ",ENUM_OPTION_DOC_PREFIX,ENUM_OPTION_DOC_SUFFIX)); + } + + /** + * Composes the help string for enum options that provide additional documentation. + * @param enumConstants the enum constants. Assumed non-null. + * @param the enum type. + * @return never {@code null}. + */ + private & ClpEnum> String getEnumOptionsWithDescription(final T[] enumConstants) { + final String optionsString = Stream.of(enumConstants) + .map(c -> String.format("%s (%s)",c.name(),c.getHelpDoc())) + .collect(Collectors.joining("\n")); + return String.join("\n",ENUM_OPTION_DOC_PREFIX,optionsString,ENUM_OPTION_DOC_SUFFIX); + } + + /** + * Returns the help string with details about valid options for the given argument class. + * + *

+ * Currently this only make sense with {@link Boolean} and {@link Enum}. Any other class + * will result in an empty string. + *

+ * + * @param clazz the target argument's class. + * @return never {@code null}. + */ + @SuppressWarnings({"unchecked","rawtypes"}) + private String getOptions(final Class clazz) { + if (clazz == Boolean.class) { + return getBooleanOptions(); + } else if (clazz.isEnum()) { + final Class enumClass = (Class)clazz; + return getEnumOptions(enumClass); + } else { + return ""; + } + } + + private void printSpaces(final PrintStream stream, final int numSpaces) { + final StringBuilder sb = new StringBuilder(); + for (int i = 0; i < numSpaces; ++i) { + sb.append(" "); + } + stream.print(sb); + } + + private void handleArgumentAnnotation( + final Field field, final Object parent, final CommandLinePluginDescriptor controllingDescriptor) { + try { + field.setAccessible(true); + final Argument argumentAnnotation = field.getAnnotation(Argument.class); + final boolean isCollection = isCollectionField(field); + if (isCollection) { + field.setAccessible(true); + if (field.get(parent) == null) { + createCollection(field, parent, "@Argument"); + } + } + if (!canBeMadeFromString(CommandLineParser.getUnderlyingType(field))) { + throw new CommandLineException.CommandLineParserInternalException("@Argument member \"" + field.getName() + + "\" must have a String constructor or be an enum"); + } + + final ArgumentDefinition argumentDefinition = new ArgumentDefinition(field, argumentAnnotation, parent, controllingDescriptor); + + for (final String argument : argumentAnnotation.mutex()) { + final ArgumentDefinition mutextArgumentDef = argumentMap.get(argument); + if (mutextArgumentDef != null) { + mutextArgumentDef.mutuallyExclusive.add(getArgumentNameForMutex(field, argumentAnnotation)); + } + } + if (inArgumentMap(argumentDefinition)) { + throw new CommandLineException.CommandLineParserInternalException(argumentDefinition.getNames() + " has already been used."); + } else { + putInArgumentMap(argumentDefinition); + argumentDefinitions.add(argumentDefinition); + } + } catch (final IllegalAccessException e) { + throw new CommandLineException.ShouldNeverReachHereException("We should not have reached here because we set accessible to true", e); + } + } + + private String getArgumentNameForMutex(final Field field, final Argument argumentAnnotation) { + if (!argumentAnnotation.fullName().isEmpty()) { + return argumentAnnotation.fullName(); + } else if (!argumentAnnotation.shortName().isEmpty()) { + return argumentAnnotation.shortName(); + } else { + return field.getName(); + } + } + + private void handlePositionalArgumentAnnotation(final Field field, Object parent) { + if (positionalArguments != null) { + throw new CommandLineException.CommandLineParserInternalException + ("@PositionalArguments cannot be used more than once in an argument class."); + } + field.setAccessible(true); + positionalArguments = field; + positionalArgumentsParent = parent; + if (!isCollectionField(field)) { + throw new CommandLineException.CommandLineParserInternalException("@PositionalArguments must be applied to a Collection"); + } + + if (!canBeMadeFromString(CommandLineParser.getUnderlyingType(field))) { + throw new CommandLineException.CommandLineParserInternalException("@PositionalParameters member " + field.getName() + + "does not have a String ctor"); + } + + final PositionalArguments positionalArgumentsAnnotation = field.getAnnotation(PositionalArguments.class); + minPositionalArguments = positionalArgumentsAnnotation.minElements(); + maxPositionalArguments = positionalArgumentsAnnotation.maxElements(); + if (minPositionalArguments > maxPositionalArguments) { + throw new CommandLineException.CommandLineParserInternalException("In @PositionalArguments, minElements cannot be > maxElements"); + } + try { + field.setAccessible(true); + if (field.get(parent) == null) { + createCollection(field, parent, "@PositionalParameters"); + } + } catch (final IllegalAccessException e) { + throw new CommandLineException.ShouldNeverReachHereException("We should not have reached here because we set accessible to true", e); + + } + } + + + private static boolean isCollectionField(final Field field) { + try { + field.getType().asSubclass(Collection.class); + return true; + } catch (final ClassCastException e) { + return false; + } + } + + private void createCollection(final Field field, final Object callerArguments, final String annotationType) + throws IllegalAccessException { + try { + field.set(callerArguments, field.getType().newInstance()); + } catch (final Exception ex) { + try { + field.set(callerArguments, new ArrayList<>()); + } catch (final IllegalArgumentException e) { + throw new CommandLineException.CommandLineParserInternalException("In collection " + annotationType + + " member " + field.getName() + + " cannot be constructed or auto-initialized with ArrayList, so collection must be initialized explicitly."); + } + + } + + } + + // True if clazz is an enum, or if it has a ctor that takes a single String argument. + private boolean canBeMadeFromString(final Class clazz) { + if (clazz.isEnum()) { + return true; + } + try { + // Need to use getDeclaredConstructor() instead of getConstructor() in case the constructor + // is non-public + clazz.getDeclaredConstructor(String.class); + return true; + } catch (final NoSuchMethodException e) { + return false; + } + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + private Object constructFromString(final Class clazz, final String s, final String argumentName) { + try { + if (clazz.isEnum()) { + try { + return Enum.valueOf(clazz, s); + } catch (final IllegalArgumentException e) { + throw new CommandLineException.BadArgumentValue(argumentName, s, "'" + s + "' is not a valid value for " + + clazz.getSimpleName() + ". "+ getEnumOptions(clazz) ); + } + } + // Need to use getDeclaredConstructor() instead of getConstructor() in case the constructor + // is non-public. Set it to be accessible if it isn't already. + final Constructor ctor = clazz.getDeclaredConstructor(String.class); + ctor.setAccessible(true); + return ctor.newInstance(s); + } catch (final NoSuchMethodException e) { + // Shouldn't happen because we've checked for presence of ctor + throw new CommandLineException.ShouldNeverReachHereException("Cannot find string ctor for " + clazz.getName(), e); + } catch (final InstantiationException e) { + throw new CommandLineException.CommandLineParserInternalException("Abstract class '" + clazz.getSimpleName() + + "'cannot be used for an argument value type.", e); + } catch (final IllegalAccessException e) { + throw new CommandLineException.CommandLineParserInternalException("String constructor for argument value type '" + clazz.getSimpleName() + + "' must be public.", e); + } catch (final InvocationTargetException e) { + throw new CommandLineException.BadArgumentValue(argumentName, s, "Problem constructing " + clazz.getSimpleName() + + " from the string '" + s + "'."); + } + } + + protected static class ArgumentDefinition { + final Field field; + final String fieldName; + final String fullName; + final String shortName; + final String doc; + final boolean optional; + final boolean isCollection; + final String defaultValue; + final boolean isCommon; + boolean hasBeenSet = false; + final Set mutuallyExclusive; + final Object parent; + final boolean isSpecial; + final boolean isSensitive; + final CommandLinePluginDescriptor controllingDescriptor; + + public ArgumentDefinition( + final Field field, + final Argument annotation, + final Object parent, + final CommandLinePluginDescriptor controllingDescriptor) { + this.field = field; + this.fieldName = field.getName(); + this.parent = parent; + this.fullName = annotation.fullName(); + this.shortName = annotation.shortName(); + this.doc = annotation.doc(); + this.isCollection = isCollectionField(field); + + this.isCommon = annotation.common(); + this.isSpecial = annotation.special(); + this.isSensitive = annotation.sensitive(); + + this.mutuallyExclusive = new LinkedHashSet<>(Arrays.asList(annotation.mutex())); + this.controllingDescriptor = controllingDescriptor; + + Object tmpDefault = getFieldValue(); + if (tmpDefault != null) { + if (isCollection && ((Collection) tmpDefault).isEmpty()) { + //treat empty collections the same as uninitialized primitive types + this.defaultValue = NULL_STRING; + } else { + //this is an initialized primitive type or a non-empty collection + this.defaultValue = tmpDefault.toString(); + } + } else { + this.defaultValue = NULL_STRING; + } + + //null collections have been initialized by createCollection which is called in handleArgumentAnnotation + //this is optional if it's specified as being optional or if there is a default value specified + this.optional = annotation.optional() || ! this.defaultValue.equals(NULL_STRING); + } + + + public Object getFieldValue(){ + try { + field.setAccessible(true); + return field.get(parent); + } catch (IllegalAccessException e) { + throw new CommandLineException.ShouldNeverReachHereException("This shouldn't happen since we setAccessible(true).", e); + } + } + + public void setFieldValue(final Object value){ + try { + field.setAccessible(true); + field.set(parent, value); + } catch (IllegalAccessException e) { + throw new CommandLineException.ShouldNeverReachHereException("BUG: couldn't set field value. For " + + fieldName +" in " + parent.toString() + " with value " + value.toString() + + " This shouldn't happen since we setAccessible(true)", e); + } + } + + public boolean isFlag(){ + return field.getType().equals(boolean.class) || field.getType().equals(Boolean.class); + } + + /** + * Determine if this argument definition is controlled by a plugin descriptor (and thus subject to + * descriptor dependency validation). + * @return + */ + public boolean isControlledByPlugin() { return controllingDescriptor != null; } + + public List getNames(){ + List names = new ArrayList<>(); + if (!shortName.isEmpty()){ + names.add(shortName); + } + if (!fullName.isEmpty()){ + names.add(fullName); + } else { + names.add(fieldName); + } + return names; + } + + public String getLongName(){ + return !fullName.isEmpty() ? fullName : fieldName; + } + + /** + * Comparator for sorting ArgumentDefinitions in alphabetical order b y longName + */ + public static Comparator sortByLongName = new Comparator() { + public int compare(ArgumentDefinition argDef1, ArgumentDefinition argDef2) { + return String.CASE_INSENSITIVE_ORDER.compare(argDef1.getLongName(), argDef2.getLongName()); + } + }; + + /** + * Helper for pretty printing this option. + * @param value A value this argument was given + * @return a string + * + */ + private String prettyNameValue(Object value) { + if(value != null){ + if (isSensitive){ + return String.format("--%s ***********", getLongName()); + } else { + return String.format("--%s %s", getLongName(), value); + } + } + return ""; + } + + /** + * @return A string representation of this argument and it's value(s) which would be valid if copied and pasted + * back as a command line argument + */ + public String toCommandLineString(){ + Object value = getFieldValue(); + if (this.isCollection){ + Collection collect = (Collection)value; + return collect.stream() + .map(this::prettyNameValue) + .collect(Collectors.joining(" ")); + + } else { + return prettyNameValue(value); + } + } + + } + + /** + * The commandline used to run this program, including any default args that + * weren't necessarily specified. This is used for logging and debugging. + *

+ * NOTE: {@link #parseArguments(PrintStream, String[])} must be called before + * calling this method. + * + * @return The commandline, or null if {@link #parseArguments(PrintStream, String[])} + * hasn't yet been called, or didn't complete successfully. + */ + @SuppressWarnings("unchecked") + @Override + public String getCommandLine() { + final String toolName = callerArguments.getClass().getName(); + final StringBuilder commandLineString = new StringBuilder(); + + final List positionalArgs; + if( positionalArguments != null) { + try { + positionalArguments.setAccessible(true); + positionalArgs = (List) positionalArguments.get(positionalArgumentsParent); + } catch (IllegalAccessException e) { + throw new CommandLineException.ShouldNeverReachHereException("Should never reach here because we setAccessible(true)", e); + } + for (final Object posArg : positionalArgs) { + commandLineString.append(" ").append(posArg.toString()); + } + } + + //first, append args that were explicitly set + commandLineString.append(argumentDefinitions.stream() + .filter(argumentDefinition -> argumentDefinition.hasBeenSet) + .map(ArgumentDefinition::toCommandLineString) + .collect(Collectors.joining(" ", " ", " "))) + //next, append args that weren't explicitly set, but have a default value + .append(argumentDefinitions.stream() + .filter(argumentDefinition -> !argumentDefinition.hasBeenSet && !argumentDefinition.defaultValue.equals(NULL_STRING)) + .map(ArgumentDefinition::toCommandLineString) + .collect(Collectors.joining(" "))); + + return toolName + " " + commandLineString.toString(); + } + +} diff --git a/src/main/java/org/broadinstitute/barclay/argparser/CommandLineException.java b/src/main/java/org/broadinstitute/barclay/argparser/CommandLineException.java new file mode 100644 index 00000000..f642b4ea --- /dev/null +++ b/src/main/java/org/broadinstitute/barclay/argparser/CommandLineException.java @@ -0,0 +1,73 @@ +package org.broadinstitute.barclay.argparser; + +/** + * Exceptions thrown by CommandLineParser implementations. + */ +public class CommandLineException extends RuntimeException { + private static final long serialVersionUID = 1L; + + public CommandLineException( String msg ) { + super(msg); + } + + public CommandLineException( String message, Throwable throwable ) { + super(message, throwable); + } + + // todo -- fix up exception cause passing + public static class MissingArgument extends CommandLineException { + private static final long serialVersionUID = 0L; + + public MissingArgument(String arg, String message) { + super(String.format("Argument %s was missing: %s", arg, message)); + } + } + + public static class BadArgumentValue extends CommandLineException { + private static final long serialVersionUID = 0L; + + public BadArgumentValue(String arg, String value) { + super(String.format("Argument %s has a bad value: %s", arg, value)); + } + + public BadArgumentValue(String arg, String value, String message){ + super(String.format("Argument %s has a bad value: %s. %s", arg, value,message)); + } + + public BadArgumentValue(String message) { + super(String.format("Illegal argument value: %s", message)); + } + } + + /** + *

+ * Class CommandLineParserInternalException + *

+ * For internal errors in the command line parser not related to syntax errors in the command line itself. + */ + public static class CommandLineParserInternalException extends CommandLineException { + private static final long serialVersionUID = 0L; + public CommandLineParserInternalException( final String s ) { + super(s); + } + + public CommandLineParserInternalException( final String s, final Throwable throwable ) { + super(s, throwable); + } + } + + /** + * For wrapping errors that are believed to never be reachable + */ + public static class ShouldNeverReachHereException extends CommandLineException { + private static final long serialVersionUID = 0L; + public ShouldNeverReachHereException( final String s ) { + super(s); + } + public ShouldNeverReachHereException( final String s, final Throwable throwable ) { + super(s, throwable); + } + public ShouldNeverReachHereException( final Throwable throwable) {this("Should never reach here.", throwable);} + } + +} diff --git a/src/main/java/org/broadinstitute/barclay/argparser/CommandLineParser.java b/src/main/java/org/broadinstitute/barclay/argparser/CommandLineParser.java new file mode 100644 index 00000000..23fcccde --- /dev/null +++ b/src/main/java/org/broadinstitute/barclay/argparser/CommandLineParser.java @@ -0,0 +1,201 @@ +package org.broadinstitute.barclay.argparser; + +import org.apache.commons.lang3.tuple.Pair; + +import java.io.PrintStream; +import java.lang.reflect.Field; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Collection; + +/** + * Interface for command line argument parsers. + */ +public interface CommandLineParser { + + /** + * Parse command-line arguments in an object passed to the implementing class ctor. + * + * @param messageStream Where to write error messages. + * @param args Command line tokens. + * @return true if command line is valid and the program should run, false if help or version was requested + * @throws CommandLineException if there is an invalid command line + */ + public boolean parseArguments(final PrintStream messageStream, final String[] args); + + /** + * The commandline used to run this program, including any default args that + * weren't necessarily specified. This is used for logging and debugging. + *

+ * NOTE: {@link #parseArguments(PrintStream, String[])} must be called before + * calling this method. + * + * @return The commandline, or null if {@link #parseArguments(PrintStream, String[])} + * hasn't yet been called, or didn't complete successfully. + */ + public String getCommandLine(); + + /** + * A typical command line program will call this to get the beginning of the usage message, + * and then append a description of the program, like this: + * + * commandLineParser.getStandardUsagePreamble(getClass()) + "Frobnicates the freebozzle." + */ + public abstract String getStandardUsagePreamble(final Class mainClass); + + public abstract String getVersion(); + + /** + * Return the plugin instance corresponding to the targetDescriptor class + */ + public default T getPluginDescriptor(Class targetDescriptor) { + // Throw unless overridden - the legacy command line parser doesn't implement plugins + throw new CommandLineException.CommandLineParserInternalException( + "Command line plugins are not implemented by this command line parser" + ); + } + + /** + * Print a usage message based on the arguments object passed to the ctor. + * + * @param stream Where to write the usage message. + * @param printCommon True if common args should be included in the usage message. + */ + public abstract void usage(final PrintStream stream, final boolean printCommon); + + /** + * Interface for @Argument annotated enums that have user documentation. + */ + public interface ClpEnum { + String getHelpDoc(); + } + + /** + * Locates and returns the VALUES of all Argument-annotated fields of a specified type in a given object, + * pairing each field value with its corresponding Field object. + * + * Must be called AFTER argument parsing and value injection into argumentSource is complete (otherwise there + * will be no values to gather!). As a result, this is implemented as a static utility method into which + * the fully-initialized tool instance must be passed. + * + * Locates Argument-annotated fields of the target type, subtypes of the target type, and Collections of + * the target type or one of its subtypes. Unpacks Collection fields, returning a separate Pair for each + * value in each Collection. + * + * Searches argumentSource itself, as well as ancestor classes, and also recurses into any ArgumentCollections + * found. + * + * Will return Pairs containing a null second element for fields having no value, including empty Collection fields + * (these represent arguments of the target type that were not specified on the command line and so never initialized). + * + * @param type Target type. Search for Argument-annotated fields that are either of this type, subtypes of this type, or Collections of this type or one of its subtypes. + * @param argumentSource Object whose fields to search. Must have already undergone argument parsing and argument value injection. + * @param Type parameter representing the type to search for and return + * @return A List of Pairs containing all Argument-annotated field values found of the target type. First element in each Pair + * is the Field object itself, and the second element is the actual value of the argument field. The second + * element will be null for uninitialized fields. + */ + public static List> gatherArgumentValuesOfType( final Class type, final Object argumentSource ) { + List> argumentValues = new ArrayList<>(); + + // Examine all fields in argumentSource (including superclasses) + for ( Field field : getAllFields(argumentSource.getClass()) ) { + field.setAccessible(true); + + try { + // Consider only fields that have Argument annotations and are either of the target type, + // subtypes of the target type, or Collections of the target type or one of its subtypes: + if ( field.getAnnotation(Argument.class) != null && type.isAssignableFrom(getUnderlyingType(field)) ) { + + if ( isCollectionField(field) ) { + // Collection arguments are guaranteed by the parsing system to be non-null (at worst, empty) + Collection argumentContainer = (Collection)field.get(argumentSource); + + // Emit a Pair with an explicit null value for empty Collection arguments + if ( argumentContainer.isEmpty() ) { + argumentValues.add(Pair.of(field, null)); + } + // Unpack non-empty Collections of the target type into individual values, + // each paired with the same Field object. + else { + for ( Object argumentValue : argumentContainer ) { + argumentValues.add(Pair.of(field, type.cast(argumentValue))); + } + } + } + else { + // Add values for non-Collection arguments of the target type directly + argumentValues.add(Pair.of(field, type.cast(field.get(argumentSource)))); + } + } + else if ( field.getAnnotation(ArgumentCollection.class) != null ) { + // Recurse into ArgumentCollections for more potential matches. + argumentValues.addAll(gatherArgumentValuesOfType(type, field.get(argumentSource))); + } + } + catch ( IllegalAccessException e ) { + throw new CommandLineException.ShouldNeverReachHereException("field access failed after setAccessible(true)"); + } + } + + return argumentValues; + } + + /** + * Returns the type that each instance of the argument needs to be converted to. In + * the case of primitive fields it will return the wrapper type so that String + * constructors can be found. + */ + static Class getUnderlyingType(final Field field) { + if (isCollectionField(field)) { + final ParameterizedType clazz = (ParameterizedType) (field.getGenericType()); + final Type[] genericTypes = clazz.getActualTypeArguments(); + if (genericTypes.length != 1) { + throw new CommandLineException.CommandLineParserInternalException("Strange collection type for field " + + field.getName()); + } + + // If the Collection's parametrized type is itself parametrized (eg., List>), + // return the raw type of the outer parameter (Foo.class, in this example) to avoid a + // ClassCastException. Otherwise, return the Collection's type parameter directly as a Class. + return (Class) (genericTypes[0] instanceof ParameterizedType ? + ((ParameterizedType)genericTypes[0]).getRawType() : + genericTypes[0]); + + } else { + final Class type = field.getType(); + if (type == Byte.TYPE) return Byte.class; + if (type == Short.TYPE) return Short.class; + if (type == Integer.TYPE) return Integer.class; + if (type == Long.TYPE) return Long.class; + if (type == Float.TYPE) return Float.class; + if (type == Double.TYPE) return Double.class; + if (type == Boolean.TYPE) return Boolean.class; + + return type; + } + } + + static List getAllFields(Class clazz) { + final List ret = new ArrayList<>(); + do { + ret.addAll(Arrays.asList(clazz.getDeclaredFields())); + clazz = clazz.getSuperclass(); + } while (clazz != null); + return ret; + } + + public static boolean isCollectionField(final Field field) { + try { + field.getType().asSubclass(Collection.class); + return true; + } catch (final ClassCastException e) { + return false; + } + } + + +} diff --git a/src/main/java/org/broadinstitute/barclay/argparser/CommandLinePluginDescriptor.java b/src/main/java/org/broadinstitute/barclay/argparser/CommandLinePluginDescriptor.java new file mode 100644 index 00000000..fc492e93 --- /dev/null +++ b/src/main/java/org/broadinstitute/barclay/argparser/CommandLinePluginDescriptor.java @@ -0,0 +1,166 @@ +package org.broadinstitute.barclay.argparser; + +import java.util.List; +import java.util.Set; +import java.util.function.Predicate; + +/** + * A base class for descriptors for plugins that can be dynamically discovered by the + * command line parser and specified as command line arguments. An instance of each + * plugin descriptor to be used should be passed to the command line parser, and will + * be queried to find the class and package names to search for all plugin classes + * that should be discovered dynamically. The command line parser will find all such + * classes, and delegate to the descriptor to obtain the corresponding plugin instance; + * the object returned to the parser is then added to the parser's list of argument sources. + * + * Descriptors (sub)classes should have at least one @Argument used to accumulate the + * user-specified instances of the plugin seen on the command line. Allowed values for + * this argument are the simple class names of the discovered plugin subclasses. + * + * Plugin (sub)classes: + * + * - should subclass a common base class (the name of which is returned by the descriptor) + * - may live in any one of the packages returned by the descriptor {@Link #getPackageNames}, + * but must have a unique simple name to avoid command line name collisions. + * - should contain @Arguments for any values they wish to collect. @Arguments may be + * optional or required. If required, the arguments are in effect "provisionally + * required" in that they are contingent on the specific plugin being specified on + * the command line; they will only be marked by the command line parser as missing + * if the they have not been specified on the command line, and the plugin class + * containing the plugin argument *has* been specified on the command line (as + * determined by the command line parser via a call to isDependentArgumentAllowed). + * + * NOTE: plugin class @Arguments that are marked "optional=false" should be not have a primitive + * type, and should not have an initial value, as the command line parser will interpret these as + * having been set even if they have not been specified on the command line. Conversely, @Arguments + * that are optional=true should have an initial value, since they parser will not require them + * to be set in the command line. + * + * The methods for each descriptor are called in the following order: + * + * getPluginClass()/getPackageNames() - once when argument parsing begins (if the descriptor + * has been passed to the command line parser as a target descriptor) + * + * getClassFilter() - once for each plugin subclass found + * getInstance() - once for each plugin subclass that isn't filtered out by getClassFilter + * validateDependentArgumentAllowed - once for each plugin argument value that has been + * specified on the command line for a plugin that is controlled by this descriptor + * + * validateArguments() - once when argument parsing is complete + * getAllInstances() - whenever the pluggable class consumer wants the resulting plugin instances + * + * getAllowedValuesForDescriptorArgument is only called when the command line parser is constructing + * a help/usage message. + */ +public abstract class CommandLinePluginDescriptor { + + /** + * Return a display name to identify this plugin to the user + * @return A short user-friendly name for this plugin. + */ + public String getDisplayName() { return getPluginClass().getSimpleName(); } + + /** + * Base class for all command line plugin classes managed by this descriptor. Subclasses of + * this class in any of the packages returned by {@link #getPackageNames} will be command line + * accessible. + */ + public abstract Class getPluginClass(); + + /** + * List of package names from which to load command line plugin classes. + * + * Note that the simple name of each class must be unique, even across packages. + * @return List of package names. + */ + public abstract List getPackageNames(); + + /** + * Give this descriptor a chance to filter out any classes it doesn't want to be + * dynamically discoverable. + * @return false if the class shouldn't be used; otherwise true + */ + public Predicate> getClassFilter() { return c -> true;} + + /** + * Return an instance of the specified pluggable class. The descriptor should + * instantiate or otherwise obtain (possibly by having been provided an instance + * through the descriptor's constructor) an instance of this plugin class. + * The descriptor should maintain a list of these instances so they can later + * be retrieved by {@link #getAllInstances}. + * + * In addition, implementations should recognize and reject any attempt to instantiate + * a second instance of a plugin that has the same simple class name as another plugin + * controlled by this descriptor (which can happen if they have different qualified names + * within the base package used by the descriptor) since the user has no way to disambiguate + * these on the command line). + * + * @param pluggableClass a plugin class discovered by the command line parser that + * was not rejected by {@link #getClassFilter} + * @return the instantiated object that will be used by the command line parser + * as an argument source + * @throws IllegalAccessException + * @throws InstantiationException + */ + public abstract Object getInstance(Class pluggableClass) + throws IllegalAccessException, InstantiationException; + + /** + * Return the allowable values for the String argument of this plugin descriptor + * that is specified by longArgName. Called by the command line parser to generate + * a usage string. If the value is unrecognized, the implementation should throw + * IllegalArgumentException. + * + * @param longArgName + * @return Set of allowable values, or empty set if any value is allowed + */ + public abstract Set getAllowedValuesForDescriptorArgument(String longArgName); + + /** + * Called by the command line parser when an argument value from the class specified + * by dependentClass has been seen on the command line. + * + * Return true if the argument is allowed (i.e., this name of this class was specified + * as a predecessor on the command line) otherwise false. + * + * This method can be used by both the command line parser and the descriptor class for + * determining when to issue error messages for "dangling" arguments (dependent arguments + * for which a value has been supplied on the command line, but for which the predecessor + * argument was not supplied). + * + * When this method returns "false", the parser will issue an error message if an argument + * value in this class has been set on the command line. + * + * @param dependentClass + * @return true if the plugin for this class was specified on the command line, or the + * values in this class may be set byt he user, otherwise false + */ + public abstract boolean isDependentArgumentAllowed(Class dependentClass); + + /** + * This method is called after all command line arguments have been processed to allow + * the descriptor to validate the plugin arguments that have been specified. + * + * It is the descriptor's job to contain an argument list which will be populated + * by the command line parser with the name of each plugin specified on the command line, + * and for each such plugin, to maintain a list of the corresponding instance. This + * method gives the descriptor a chance to reduce that list to include only those + * instances actually seen on the command line. + * + * Implementations of this method should minimally validate that all of values that have + * been specified on the command line have a corresponding plugin instance (this will + * detect a user-specified value for which there is no corresponding plugin class). + * + * @throws CommandLineException if a plugin value has been specified that + * has no corresponding plugin instance (i.e., the plugin class corresponding to the name + * was not discovered) + */ + public abstract void validateArguments() throws CommandLineException; + + /** + * @return an ordered List of actual plugin instances that have been specified on the command + * line, in the same order they were obtained/created by {@line #getInstance}). + */ + public abstract List getAllInstances(); + +} diff --git a/src/main/java/org/broadinstitute/barclay/argparser/CommandLineProgramGroup.java b/src/main/java/org/broadinstitute/barclay/argparser/CommandLineProgramGroup.java new file mode 100644 index 00000000..3fe21196 --- /dev/null +++ b/src/main/java/org/broadinstitute/barclay/argparser/CommandLineProgramGroup.java @@ -0,0 +1,17 @@ +package org.broadinstitute.barclay.argparser; + +import java.util.Comparator; + +/** + * Interface for groups of CommandLinePrograms. + * @author Nils Homer + */ +public interface CommandLineProgramGroup { + + /** Gets the name of this program. **/ + public String getName(); + /** Gets the description of this program. **/ + public String getDescription(); + /** Compares two program groups by name. **/ + public static Comparator comparator = (a, b) -> a.getName().compareTo(b.getName()); +} diff --git a/src/main/java/org/broadinstitute/barclay/argparser/CommandLineProgramProperties.java b/src/main/java/org/broadinstitute/barclay/argparser/CommandLineProgramProperties.java new file mode 100644 index 00000000..127bd6a1 --- /dev/null +++ b/src/main/java/org/broadinstitute/barclay/argparser/CommandLineProgramProperties.java @@ -0,0 +1,31 @@ +package org.broadinstitute.barclay.argparser; + +import java.lang.annotation.*; + +/** + * Annotates a command line program with various properties, such as usage (short and long), + * as well as to which program group it belongs. + * + * TODO: enforced that any CommandLineProgram has this property defined (use an annotation processor?). + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +@Inherited +public @interface CommandLineProgramProperties { + /** + * @return a summary of what the program does + */ + String summary(); + + /** + * @return a very short summary for the main menu list of all programs + */ + String oneLineSummary(); + + /** + * @return an example command line for this program + */ + String usageExample() default "The author of this program hasn't included any example usage, please complain to them."; + Class programGroup(); + boolean omitFromCommandLine() default false; +} diff --git a/src/main/java/org/broadinstitute/barclay/argparser/Hidden.java b/src/main/java/org/broadinstitute/barclay/argparser/Hidden.java new file mode 100644 index 00000000..4fbc84e3 --- /dev/null +++ b/src/main/java/org/broadinstitute/barclay/argparser/Hidden.java @@ -0,0 +1,13 @@ +package org.broadinstitute.barclay.argparser; + +import java.lang.annotation.*; + +/** + * Indicates that an argument should not be presented in the help system. + */ +@Documented +@Inherited +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.FIELD}) +public @interface Hidden { +} diff --git a/src/main/java/org/broadinstitute/barclay/argparser/LegacyCommandLineArgumentParser.java b/src/main/java/org/broadinstitute/barclay/argparser/LegacyCommandLineArgumentParser.java new file mode 100644 index 00000000..d7a68d0d --- /dev/null +++ b/src/main/java/org/broadinstitute/barclay/argparser/LegacyCommandLineArgumentParser.java @@ -0,0 +1,1082 @@ +/* + * The MIT License + * + * Copyright (c) 2009 The Broad Institute + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package org.broadinstitute.barclay.argparser; + +import org.apache.commons.lang3.text.WordUtils; + +import java.io.BufferedReader; +import java.io.File; +import java.io.FileReader; +import java.io.IOException; +import java.io.PrintStream; +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.regex.Pattern; + +/** + * Annotation-driven utility for parsing command-line arguments, checking for errors, and producing usage message. + *

+ * This class supports options of the form KEY=VALUE, plus positional arguments. Positional arguments must not contain + * an equal sign lest they be mistaken for a KEY=VALUE pair. + *

+ * The caller must supply an object that both defines the command line and has the parsed options set into it. + * For each possible KEY=VALUE option, there must be a public data member annotated with @Argument. The KEY name is + * the name of the data member. An abbreviated name may also be specified with the shortName attribute of @Argument. + * If the data member is a List, then the option may be specified multiple times. The type of the data member, + * or the type of the List element must either have a ctor T(String), or must be an Enum. List options must + * be initialized by the caller with some kind of list. Any other option that is non-null is assumed to have the given + * value as a default. If an option has no default value, and does not have the optional attribute of @Argument set, + * is required. For List options, minimum and maximum number of elements may be specified in the @Argument annotation. + *

+ * A single List data member may be annotated with the @PositionalArguments. This behaves similarly to a Option + * with List data member: the caller must initialize the data member, the type must be constructable from String, and + * min and max number of elements may be specified. If no @PositionalArguments annotation appears in the object, + * then it is an error for the command line to contain positional arguments. + *

+ * A single String public data member may be annotated with @Usage. This string, if present, is used to + * construct the usage message. Details about the possible options are automatically appended to this string. + * If @Usage does not appear, a boilerplate usage message is used. + */ +public class LegacyCommandLineArgumentParser implements CommandLineParser { + // For formatting option section of usage message. + private static final int OPTION_COLUMN_WIDTH = 30; + private static final int DESCRIPTION_COLUMN_WIDTH = 90; + + private static final Boolean[] TRUE_FALSE_VALUES = {Boolean.TRUE, Boolean.FALSE}; + + private static final String[] PACKAGES_WITH_WEB_DOCUMENTATION = {"picard"}; + + // Use these if no @Usage annotation + private static final String defaultUsagePreamble = "Usage: program [options...]\n"; + private static final String defaultUsagePreambleWithPositionalArguments = + "Usage: program [options...] [positional-arguments...]\n"; + private static final String OPTIONS_FILE = "OPTIONS_FILE"; + + private static final String PRECEDENCE_SYMBOL = "++"; + + /** name, shortName, description for options built in to framework */ + private static final String[][] FRAMEWORK_OPTION_DOC = { + {"--help", "-h", "Displays options specific to this tool."}, + {"--stdhelp", "-H", "Displays options specific to this tool AND " + + "options common to all Picard command line tools."}, + {"--version", null, "Displays program version."} + }; + + private final Set optionsThatCannotBeOverridden = new HashSet(); + + /** + * A typical command line program will call this to get the beginning of the usage message, + * and then append a description of the program, like this: + * + * getStandardUsagePreamble(getClass()) + "Frobnicates the freebozzle." + */ + @Override + public String getStandardUsagePreamble(final Class mainClass) { + return "USAGE: " + mainClass.getSimpleName() + " [options]\n\n" + + (hasWebDocumentation(mainClass) ? + "Documentation: http://broadinstitute.github.io/picard/command-line-overview.html#" + + mainClass.getSimpleName() + "\n\n" + : ""); + } + + /** + * Determines if a class has web documentation based on its package name + * + * @param clazz + * @return true if the class has web documentation, false otherwise + */ + public boolean hasWebDocumentation(final Class clazz) { + for (final String pkg : PACKAGES_WITH_WEB_DOCUMENTATION) { + if (clazz.getPackage().getName().startsWith(pkg)) { + return true; + } + } + return false; + } + + /** + * @return the link to a FAQ + */ + public String getFaqLink() { + return "To get help, see http://broadinstitute.github.io/picard/index.html#GettingHelp"; + } + + // This is the object that the caller has provided that contains annotations, + // and into which the values will be assigned. + private final Object callerOptions; + + // For child CommandLineParser, this contains the prefix for the option names, which is needed for generating + // the command line. For non-nested, this is the empty string. + private final String prefix; + // For non-nested, empty string. For nested, prefix + "." + private final String prefixDot; + + // null if no @PositionalArguments annotation + private Field positionalArguments; + private int minPositionalArguments; + private int maxPositionalArguments; + + // List of all the data members with @Argument annotation + private final List optionDefinitions = new ArrayList<>(); + + // Maps long name, and short name, if present, to an option definition that is + // also in the optionDefinitions list. + private final Map optionMap = new HashMap<>(); + + // For printing error messages when parsing command line. + private PrintStream messageStream; + + // In case implementation wants to get at arg for some reason. + private String[] argv; + + private String programVersion = null; + + // The command line used to launch this program, including non-null default options that + // weren't explicitly specified. This is used for logging and debugging. + private String commandLine = ""; + + // The associated program properties using the CommandLineProgramProperties annotation + private final CommandLineProgramProperties programProperties; + + /** + * Prepare for parsing command line arguments, by validating annotations. + * + * @param callerOptions This object contains annotations that define the acceptable command-line options, + * and ultimately will receive the settings when a command line is parsed. + */ + public LegacyCommandLineArgumentParser(final Object callerOptions) { + this(callerOptions, ""); + } + + private String getUsagePreamble() { + String usagePreamble = ""; + if (null != programProperties) { + usagePreamble += programProperties.summary(); + } else if (positionalArguments == null) { + usagePreamble += defaultUsagePreamble; + } else { + usagePreamble += defaultUsagePreambleWithPositionalArguments; + } + + if (null != this.programVersion && 0 < this.programVersion.length()) { + usagePreamble += "Version: " + getVersion() + "\n"; + } + //checkForNonASCII(usagePreamble, "preamble"); + + return usagePreamble; + } + + /** + * @param prefix Non-empty for child options object. + */ + private LegacyCommandLineArgumentParser(final Object callerOptions, final String prefix) { + this.callerOptions = callerOptions; + + this.prefix = prefix; + if (prefix.isEmpty()) { + prefixDot = ""; + } else { + prefixDot = prefix + "."; + } + + int fieldCounter = 1; + for (final Field field : CommandLineParser.getAllFields(this.callerOptions.getClass())) { + if (field.getAnnotation(PositionalArguments.class) != null) { + handlePositionalArgumentAnnotation(field); + } + if (field.getAnnotation(Argument.class) != null) { + handleOptionAnnotation(field, fieldCounter); + // only increase counter if the field had default printOrder + if (field.getAnnotation(Argument.class).printOrder() == Integer.MAX_VALUE) { + fieldCounter++; + } + } + } + + // make sure to sort options according to printOrder + if (optionDefinitions != null && !optionDefinitions.isEmpty()){ + Collections.sort(optionDefinitions, new OptionDefinitionByPrintOrderComparator()); + } + + this.programProperties = this.callerOptions.getClass().getAnnotation(CommandLineProgramProperties.class); + } + + @Override + public String getVersion() { + return this.callerOptions.getClass().getPackage().getImplementationVersion(); + } + + /** + * Print a usage message based on the options object passed to the ctor. + * + * @param stream Where to write the usage message. + */ + @Override + public void usage(final PrintStream stream, final boolean printCommon) { + + if (prefix.isEmpty()) { + final String preamble = htmlUnescape(convertFromHtml(getStandardUsagePreamble(callerOptions.getClass()) + getUsagePreamble())); + checkForNonASCII(preamble, "Tool description"); + stream.print(preamble); + stream.println("\nVersion: " + getVersion()); + stream.println("\n\nOptions:\n"); + + for (final String[] optionDoc : FRAMEWORK_OPTION_DOC) { + printOptionParamUsage(stream, optionDoc[0], optionDoc[1], null, optionDoc[2]); + } + } + + if (!optionDefinitions.isEmpty()) { + optionDefinitions.stream().filter(optionDefinition -> printCommon || !optionDefinition.isCommon).forEach(optionDefinition -> printOptionUsage(stream, optionDefinition)); + } + + if (printCommon) { + final Field fileField; + try { + //Temp class OPTIONS_FILE + class OptionFileContainerForUsage { public File optionFileContainer;} + fileField = OptionFileContainerForUsage.class.getField("optionFileContainer"); + } catch (final NoSuchFieldException e) { + throw new CommandLineException("Should never happen", e); + } + final OptionDefinition optionsFileOptionDefinition = + new OptionDefinition(fileField, OPTIONS_FILE, "", + "File of OPTION_NAME=value pairs. No positional parameters allowed. Unlike command-line options, " + + "unrecognized options are ignored. " + "A single-valued option set in an options file may be overridden " + + "by a subsequent command-line option. " + + "A line starting with '#' is considered a comment.", + false, true, false, 0, Integer.MAX_VALUE, null, true, new String[0], Integer.MAX_VALUE); + printOptionUsage(stream, optionsFileOptionDefinition); + } + } + + static void checkForNonASCII(String documentationText, String location) { + if (documentationText.matches("[^\\p{ASCII}]")) { + throw new AssertionError("Non-ASCII character used in documentation ("+location+"). Only ASCII characters are allowed."); + } + //make sure that html-encoded non-ascii characters are found as well + if ( Pattern.compile(".*&[a-zA-Z]*?;.*",Pattern.MULTILINE).matcher(documentationText).find()) { + throw new AssertionError("Non-ASCII character used in documentation ("+location+"). Only ASCII characters are allowed."); + } + } + // package local for testing + static String convertFromHtml(final String textToConvert) { + + //LinkedHashmap since the order matters + final Map regexps = new LinkedHashMap<>(); + + regexps.put("< *a *href=[\'\"](.*?)[\'\"] *>(.*?)","$2 ($1)"); + regexps.put("< *a *href=[\'\"](.*?)[\'\"] *>(.*?)< *a */>","$2 ($1)"); + regexps.put("","\n"); + regexps.put("< *(br|p|table|h[1-4]|pre|hr|li|ul) */>","\n"); + regexps.put("< *(p|table|h[1-4]|ul|pre) *>","\n"); + regexps.put("

  • ", " - "); + regexps.put("", "\t"); + regexps.put("<\\w*?>", ""); + + return regexps.entrySet().stream().sequential() + .reduce(textToConvert, (string, entrySet) -> string.replaceAll(entrySet.getKey(), entrySet.getValue()), (a, b) -> b); + } + + private static final Map htmlToText = new LinkedHashMap(){ + private static final long serialVersionUID = 1L; + { + put("<","<"); + put(">",">"); + put("≥",">="); + put("≤","<="); + + put("

    ","\n"); + } + }; + + static String htmlUnescape(String str) { + // May need more here + return htmlToText.entrySet().stream().sequential() + .reduce(str, (string, entrySet) -> string.replace(entrySet.getKey(), entrySet.getValue()), (a, b) -> b); + } + + /** + * Parse command-line options, and store values in callerOptions object passed to ctor. + * + * @param messageStream Where to write error messages. + * @param args Command line tokens. + * @return true if command line is valid. + */ + @Override + public boolean parseArguments(final PrintStream messageStream, final String[] args) { + this.argv = args; + this.messageStream = messageStream; + if (prefix.isEmpty()) { + commandLine = callerOptions.getClass().getName(); + } + for (int i = 0; i < args.length; ++i) { + final String arg = args[i]; + if (arg.equals("-h") || arg.equals("--help")) { + usage(messageStream, false); + return false; + } + if (arg.equals("-H") || arg.equals("--stdhelp")) { + usage(messageStream, true); + return false; + } + + if (arg.equals("--version")) { + messageStream.println(getVersion()); + return false; + } + + final String[] pair = arg.split("=", 2); + if (pair.length == 2) { + if (pair[1].isEmpty() && i < args.length - 1) { + pair[1] = args[++i]; + } + if (!parseOption(pair[0], pair[1], false)) { + messageStream.println(); + usage(messageStream, true); + return false; + } + } else if (!parsePositionalArgument(arg)) { + messageStream.println(); + usage(messageStream, false); + return false; + } + } + if (!checkNumArguments()) { + messageStream.println(); + usage(messageStream, false); + return false; + } + + return true; + } + + /** + * After command line has been parsed, make sure that all required options have values, and that + * lists with minimum # of elements have sufficient. + * + * @return true if valid + */ + private boolean checkNumArguments() { + //Also, since we're iterating over all options and args, use this opportunity to recreate the commandLineString + final StringBuilder commandLineString = new StringBuilder(); + try { + for (final OptionDefinition optionDefinition : optionDefinitions) { + final String fullName = prefixDot + optionDefinition.name; + final StringBuilder mutextOptionNames = new StringBuilder(); + for (final String mutexOption : optionDefinition.mutuallyExclusive) { + final OptionDefinition mutextOptionDef = optionMap.get(mutexOption); + if (mutextOptionDef != null && mutextOptionDef.hasBeenSet) { + mutextOptionNames.append(' ').append(prefixDot).append(mutextOptionDef.name); + } + } + if (optionDefinition.hasBeenSet && mutextOptionNames.length() > 0) { + messageStream.println("ERROR: Option '" + fullName + + "' cannot be used in conjunction with option(s)" + + mutextOptionNames.toString()); + return false; + } + if (optionDefinition.isCollection) { + final Collection c = (Collection) optionDefinition.field.get(callerOptions); + if (c.size() < optionDefinition.minElements) { + messageStream.println("ERROR: Option '" + fullName + "' must be specified at least " + + optionDefinition.minElements + " times."); + return false; + } + } else if (!optionDefinition.optional && !optionDefinition.hasBeenSet && + !optionDefinition.hasBeenSetFromParent && mutextOptionNames.length() == 0) { + messageStream.print("ERROR: Option '" + fullName + "' is required"); + if (optionDefinition.mutuallyExclusive.isEmpty()) { + messageStream.println("."); + } else { + messageStream.println(" unless any of " + optionDefinition.mutuallyExclusive + + " are specified."); + } + return false; + } + } + + if (positionalArguments != null) { + final Collection c = (Collection) positionalArguments.get(callerOptions); + if (c.size() < minPositionalArguments) { + messageStream.println("ERROR: At least " + minPositionalArguments + + " positional arguments must be specified."); + return false; + } + for (final Object posArg : c) { + commandLineString.append(' ').append(posArg.toString()); + } + } + //first, append args that were explicitly set + for (final OptionDefinition optionDefinition : optionDefinitions) { + if (optionDefinition.hasBeenSet) { + commandLineString.append(' ').append(prefixDot).append(optionDefinition.name).append('=').append( + optionDefinition.field.get(callerOptions)); + } + } + commandLineString.append(" "); //separator to tell the 2 apart + //next, append args that weren't explicitly set, but have a default value + for (final OptionDefinition optionDefinition : optionDefinitions) { + if (!optionDefinition.hasBeenSet && !optionDefinition.defaultValue.equals("null")) { + commandLineString.append(' ').append(prefixDot).append(optionDefinition.name).append('=').append( + optionDefinition.defaultValue); + } + } + this.commandLine += commandLineString.toString(); + return true; + } catch (final IllegalAccessException e) { + // Should never happen because lack of publicness has already been checked. + throw new RuntimeException(e); + } + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + private boolean parsePositionalArgument(final String stringValue) { + if (positionalArguments == null) { + messageStream.println("ERROR: Invalid argument '" + stringValue + "'."); + return false; + } + final Object value; + try { + value = constructFromString(getUnderlyingType(positionalArguments), stringValue); + } catch (final CommandLineException e) { + messageStream.println("ERROR: " + e.getMessage()); + return false; + } + final Collection c; + try { + c = (Collection) positionalArguments.get(callerOptions); + } catch (final IllegalAccessException e) { + throw new RuntimeException(e); + } + if (c.size() >= maxPositionalArguments) { + messageStream.println("ERROR: No more than " + maxPositionalArguments + + " positional arguments may be specified on the command line."); + return false; + } + c.add(value); + return true; + } + + private boolean parseOption(final String key, final String stringValue, final boolean optionsFile) { + return parseOption(key, stringValue, optionsFile, false); + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + private boolean parseOption(String key, final String stringValue, final boolean optionsFile, + boolean precedenceSet) { + key = key.toUpperCase(); + if (key.equals(OPTIONS_FILE)) { + commandLine += " " + prefix + OPTIONS_FILE + "=" + stringValue; + return parseOptionsFile(stringValue); + } + + // Check to see if the precedence symbol was used + if (key.startsWith(PRECEDENCE_SYMBOL)) { + key = key.substring(PRECEDENCE_SYMBOL.length()); + precedenceSet = true; + } + + final OptionDefinition optionDefinition = optionMap.get(key); + if (optionDefinition == null) { + if (optionsFile) { + // Silently ignore unrecognized option from options file + return true; + } + messageStream.println("ERROR: Unrecognized option: " + key); + return false; + } + + // Check to see if the option has been "fixed" already + if (this.optionsThatCannotBeOverridden.contains(optionDefinition.name)) { + return true; + } else if (precedenceSet) { + this.optionsThatCannotBeOverridden.add(optionDefinition.name); + } + + if (!optionDefinition.isCollection && optionDefinition.hasBeenSet && !optionDefinition.hasBeenSetFromOptionsFile) { + messageStream.println("ERROR: Option '" + key + "' cannot be specified more than once."); + return false; + } + final Object value; + try { + if (stringValue.equals("null")) { + //"null" is a special value that allows the user to override any default + //value set for this arg. It can only be used for optional args. When + //used for a list arg, it will clear the list. + if (optionDefinition.optional) { + value = null; + } else { + messageStream.println("ERROR: non-null value must be provided for '" + key + "'."); + return false; + } + } else { + value = constructFromString(getUnderlyingType(optionDefinition.field), stringValue); + } + + } catch (final CommandLineException e) { + messageStream.println("ERROR: " + e.getMessage()); + return false; + } + try { + if (optionDefinition.isCollection) { + final Collection c = (Collection) optionDefinition.field.get(callerOptions); + if (value == null) { + //user specified this arg=null which is interpreted as empty list + c.clear(); + } else if (c.size() >= optionDefinition.maxElements) { + messageStream.println("ERROR: Option '" + key + "' cannot be used more than " + + optionDefinition.maxElements + " times."); + return false; + } else { + c.add(value); + } + optionDefinition.hasBeenSet = true; + optionDefinition.hasBeenSetFromOptionsFile = optionsFile; + } else { + //get all fields with this name and set them to the argument. + final String fieldName = optionDefinition.field.getName(); + final Field[] fields = callerOptions.getClass().getFields(); + for (final Field field : fields) { + if (field.getName().equals(fieldName)) { + field.set(callerOptions, value); + optionDefinition.hasBeenSet = true; + } + } + if (!optionDefinition.hasBeenSet) { + optionDefinition.field.set(callerOptions, value); + optionDefinition.hasBeenSet = true; + } + optionDefinition.hasBeenSetFromOptionsFile = optionsFile; + } + } catch (final IllegalAccessException e) { + // Should never happen because we only iterate through public fields. + throw new RuntimeException(e); + } + return true; + } + + /** + * Parsing of options from file is looser than normal. Any unrecognized options are + * ignored, and a single-valued option that is set in a file may be overridden by a + * subsequent appearance of that option. + * A line that starts with '#' is ignored. + * + * @param optionsFile + * @return false if a fatal error occurred + */ + private boolean parseOptionsFile(final String optionsFile) { + return parseOptionsFile(optionsFile, true); + } + + /** + * @param optionFileStyleValidation true: unrecognized options are silently ignored; and a single-valued option may be overridden. + * false: standard rules as if the options in the file were on the command line directly. + * @return + */ + public boolean parseOptionsFile(final String optionsFile, final boolean optionFileStyleValidation) { + try (final BufferedReader reader = new BufferedReader(new FileReader(optionsFile))) { + String line; + while ((line = reader.readLine()) != null) { + if (line.startsWith("#") || line.trim().isEmpty()) { + continue; + } + final String[] pair = line.split("=", 2); + if (pair.length == 2) { + if (!parseOption(pair[0], pair[1], optionFileStyleValidation)) { + messageStream.println(); + usage(messageStream, true); + return false; + } + } else { + messageStream.println("Strange line in OPTIONS_FILE " + optionsFile + ": " + line); + usage(messageStream, true); + return false; + } + } + return true; + + } catch (final IOException e) { + throw new CommandLineException("I/O error loading OPTIONS_FILE=" + optionsFile, e); + } + } + + private void printHtmlOptionUsage(final PrintStream stream, final OptionDefinition optionDefinition) { + final String type = getUnderlyingType(optionDefinition.field).getSimpleName(); + final String optionLabel = prefixDot + optionDefinition.name + " (" + type + ")"; + stream.println("" + optionLabel + "" + makeOptionDescription(optionDefinition) + ""); + } + + private void printOptionUsage(final PrintStream stream, final OptionDefinition optionDefinition) { + printOptionParamUsage(stream, optionDefinition.name, optionDefinition.shortName, + getUnderlyingType(optionDefinition.field).getSimpleName(), + makeOptionDescription(optionDefinition)); + } + + + private void printOptionParamUsage(final PrintStream stream, final String name, final String shortName, + final String type, final String optionDescription) { + String optionLabel = prefixDot + name; + if (type != null) optionLabel += "=" + type; + + stream.print(optionLabel); + if (shortName != null && !shortName.isEmpty()) { + stream.println(); + optionLabel = prefixDot + shortName; + if (type != null) optionLabel += "=" + type; + stream.print(optionLabel); + } + + int numSpaces = OPTION_COLUMN_WIDTH - optionLabel.length(); + if (optionLabel.length() > OPTION_COLUMN_WIDTH) { + stream.println(); + numSpaces = OPTION_COLUMN_WIDTH; + } + printSpaces(stream, numSpaces); + checkForNonASCII(optionDescription, name); + final String wrappedDescription = WordUtils.wrap(convertFromHtml(optionDescription), DESCRIPTION_COLUMN_WIDTH); + final String[] descriptionLines = wrappedDescription.split("\n"); + for (int i = 0; i < descriptionLines.length; ++i) { + if (i > 0) { + printSpaces(stream, OPTION_COLUMN_WIDTH); + } + stream.println(descriptionLines[i]); + } + stream.println(); + } + + private String makeOptionDescription(final OptionDefinition optionDefinition) { + final StringBuilder sb = new StringBuilder(); + if (!optionDefinition.doc.isEmpty()) { + sb.append(optionDefinition.doc); + sb.append(" "); + } + if (optionDefinition.optional) { + sb.append("Default value: "); + sb.append(optionDefinition.defaultValue); + sb.append(". "); + if (!optionDefinition.defaultValue.equals("null")) { + sb.append("This option can be set to 'null' to clear the default value. "); + } + } else if (!optionDefinition.isCollection) { + sb.append("Required. "); + } + Object[] enumConstants = getUnderlyingType(optionDefinition.field).getEnumConstants(); + if (enumConstants == null && getUnderlyingType(optionDefinition.field) == Boolean.class) { + enumConstants = TRUE_FALSE_VALUES; + } + + if (enumConstants != null) { + final Boolean isClpEnum = enumConstants.length > 0 && (enumConstants[0] instanceof ClpEnum); + + sb.append("Possible values: {"); + if (isClpEnum) sb.append('\n'); + + for (int i = 0; i < enumConstants.length; ++i) { + if (i > 0 && !isClpEnum) { + sb.append(", "); + } + sb.append(enumConstants[i].toString()); + + if (isClpEnum) { + sb.append(" (").append(((ClpEnum) enumConstants[i]).getHelpDoc()).append(")\n"); + } + } + sb.append("} "); + } + if (optionDefinition.isCollection) { + if (optionDefinition.minElements == 0) { + if (optionDefinition.maxElements == Integer.MAX_VALUE) { + sb.append("This option may be specified 0 or more times. "); + } else { + sb.append("This option must be specified no more than ").append(optionDefinition.maxElements).append( + " times. "); + } + } else if (optionDefinition.maxElements == Integer.MAX_VALUE) { + sb.append("This option must be specified at least ").append(optionDefinition.minElements).append(" times. "); + } else { + sb.append("This option may be specified between ").append(optionDefinition.minElements).append( + " and ").append(optionDefinition.maxElements).append(" times. "); + } + + if (!optionDefinition.defaultValue.equals("null")) { + sb.append("This option can be set to 'null' to clear the default list. "); + } + + } + if (!optionDefinition.mutuallyExclusive.isEmpty()) { + sb.append(" Cannot be used in conjuction with option(s)"); + for (final String option : optionDefinition.mutuallyExclusive) { + final OptionDefinition mutextOptionDefinition = optionMap.get(option); + + if (mutextOptionDefinition == null) { + throw new CommandLineException("Invalid option definition in source code. " + option + + " doesn't match any known option."); + } + + sb.append(' ').append(mutextOptionDefinition.name); + if (!mutextOptionDefinition.shortName.isEmpty()) { + sb.append(" (").append(mutextOptionDefinition.shortName).append(')'); + } + } + } + return sb.toString(); + } + + private void printSpaces(final PrintStream stream, final int numSpaces) { + final StringBuilder sb = new StringBuilder(); + for (int i = 0; i < numSpaces; ++i) { + sb.append(' '); + } + stream.print(sb); + } + + /** + * @param field the command line parameter as a {@link Field} + * @param fieldPosition the field number as returned by getAllFields() that returns all fields including those of superclasses + */ + private void handleOptionAnnotation(final Field field, final int fieldPosition) { + try { + field.setAccessible(true); + final Argument optionAnnotation = field.getAnnotation(Argument.class); + final boolean isCollection = isCollectionField(field); + if (isCollection) { + if (optionAnnotation.maxElements() == 0) { + throw new CommandLineException.CommandLineParserInternalException("@Argument member " + field.getName() + + "has maxElements = 0"); + } + if (optionAnnotation.minElements() > optionAnnotation.maxElements()) { + throw new CommandLineException.CommandLineParserInternalException("In @Argument member " + field.getName() + + ", minElements cannot be > maxElements"); + } + if (field.get(callerOptions) == null) { + createCollection(field, callerOptions, "@Argument"); + } + } + if (!canBeMadeFromString(getUnderlyingType(field))) { + throw new CommandLineException.CommandLineParserInternalException("@Argument member " + field.getName() + + " must have a String ctor or be an enum"); + } + + int printOrder = optionAnnotation.printOrder(); + /* + * check if we got the default printOrder (ie the print order was not specified in + * field annotation). + * If so we use the field position to set its default print order + * *but* we multiply the field position by 1000 to + * (1) make sure that custom ordering is preserved as long as it is below 1000 + * (2) get rooms in between each options to be able to insert your own options + */ + if (printOrder == Integer.MAX_VALUE) { + printOrder = fieldPosition * 1000; + } + + + final OptionDefinition optionDefinition = new OptionDefinition(field, + field.getName(), + optionAnnotation.shortName(), + optionAnnotation.doc(), optionAnnotation.optional() || (field.get(callerOptions) != null), + optionAnnotation.overridable(), isCollection, optionAnnotation.minElements(), + optionAnnotation.maxElements(), field.get(callerOptions), optionAnnotation.common(), + optionAnnotation.mutex(), + printOrder); + + for (final String option : optionAnnotation.mutex()) { + final OptionDefinition mutextOptionDef = optionMap.get(option); + if (mutextOptionDef != null) { + mutextOptionDef.mutuallyExclusive.add(field.getName()); + } + } + if (!optionDefinition.overridable && optionMap.containsKey(optionDefinition.name)) { + throw new CommandLineException.CommandLineParserInternalException(optionDefinition.name + " has already been used."); + } + if (!optionDefinition.shortName.isEmpty() && !optionDefinition.shortName.equals(optionDefinition.name)) { + if (optionMap.containsKey(optionDefinition.shortName)) { + if (!optionDefinition.overridable) { + throw new CommandLineException.CommandLineParserInternalException(optionDefinition.shortName + + " has already been used"); + } + } else { + optionMap.put(optionDefinition.shortName, optionDefinition); + } + } + //if we are overridable and we already exist don't add again to the option defs + if (!(optionDefinition.overridable && optionMap.containsKey(optionDefinition.name))) { + optionDefinitions.add(optionDefinition); + optionMap.put(optionDefinition.name, optionDefinition); + } + //we are overridable but we already exist in the map so we need to update the hidden field value + else if (optionMap.containsKey(optionDefinition.name)) { + field.set(this.callerOptions, optionMap.get(optionDefinition.name).field.get(callerOptions)); + } + } catch (final IllegalAccessException e) { + throw new CommandLineException.CommandLineParserInternalException(field.getName() + + " must have public visibility to have @Argument annotation"); + } + } + + private void handlePositionalArgumentAnnotation(final Field field) { + if (positionalArguments != null) { + throw new CommandLineException.CommandLineParserInternalException + ("@PositionalArguments cannot be used more than once in an option class."); + } + field.setAccessible(true); + positionalArguments = field; + if (!isCollectionField(field)) { + throw new CommandLineException.CommandLineParserInternalException("@PositionalArguments must be applied to a Collection"); + } + + if (!canBeMadeFromString(getUnderlyingType(field))) { + throw new CommandLineException.CommandLineParserInternalException("@PositionalParameters member " + field.getName() + + "does not have a String ctor"); + } + + final PositionalArguments positionalArgumentsAnnotation = field.getAnnotation(PositionalArguments.class); + minPositionalArguments = positionalArgumentsAnnotation.minElements(); + maxPositionalArguments = positionalArgumentsAnnotation.maxElements(); + if (minPositionalArguments > maxPositionalArguments) { + throw new CommandLineException.CommandLineParserInternalException("In @PositionalArguments, minElements cannot be > maxElements"); + } + try { + if (field.get(callerOptions) == null) { + createCollection(field, callerOptions, "@PositionalParameters"); + } + } catch (final IllegalAccessException e) { + throw new CommandLineException.CommandLineParserInternalException(field.getName() + + " must have public visibility to have @PositionalParameters annotation"); + + } + } + + private boolean isCollectionField(final Field field) { + try { + field.getType().asSubclass(Collection.class); + return true; + } catch (final ClassCastException e) { + return false; + } + } + + private void createCollection(final Field field, final Object callerOptions, final String annotationType) + throws IllegalAccessException { + try { + field.set(callerOptions, field.getType().newInstance()); + } catch (final Exception ex) { + try { + field.set(callerOptions, new ArrayList<>()); + } catch (final IllegalArgumentException e) { + throw new CommandLineException.CommandLineParserInternalException("In collection " + annotationType + + " member " + field.getName() + + " cannot be constructed or auto-initialized with ArrayList, so collection must be initialized explicitly."); + } + + } + + } + + /** + * Returns the type that each instance of the argument needs to be converted to. In + * the case of primitive fields it will return the wrapper type so that String + * constructors can be found. + */ + private Class getUnderlyingType(final Field field) { + if (isCollectionField(field)) { + final ParameterizedType clazz = (ParameterizedType) (field.getGenericType()); + final Type[] genericTypes = clazz.getActualTypeArguments(); + if (genericTypes.length != 1) { + throw new CommandLineException.CommandLineParserInternalException("Strange collection type for field " + + field.getName()); + } + return (Class) genericTypes[0]; + + } else { + final Class type = field.getType(); + if (type == Byte.TYPE) return Byte.class; + if (type == Short.TYPE) return Short.class; + if (type == Integer.TYPE) return Integer.class; + if (type == Long.TYPE) return Long.class; + if (type == Float.TYPE) return Float.class; + if (type == Double.TYPE) return Double.class; + if (type == Boolean.TYPE) return Boolean.class; + + return type; + } + } + + // True if clazz is an enum, or if it has a ctor that takes a single String argument. + private boolean canBeMadeFromString(final Class clazz) { + if (clazz.isEnum()) { + return true; + } + try { + clazz.getConstructor(String.class); + return true; + } catch (final NoSuchMethodException e) { + return false; + } + } + + @SuppressWarnings({"rawtypes", "unchecked"}) + private Object constructFromString(final Class clazz, final String s) { + try { + if (clazz.isEnum()) { + try { + return Enum.valueOf(clazz, s); + } catch (final IllegalArgumentException e) { + throw new CommandLineException("'" + s + "' is not a valid value for " + + clazz.getSimpleName() + ".", e); + } + } + final Constructor ctor = clazz.getConstructor(String.class); + return ctor.newInstance(s); + } catch (final NoSuchMethodException e) { + // Shouldn't happen because we've checked for presence of ctor + throw new CommandLineException("Cannot find string ctor for " + clazz.getName(), e); + } catch (final InstantiationException e) { + throw new CommandLineException("Abstract class '" + clazz.getSimpleName() + + "'cannot be used for an option value type.", e); + } catch (final IllegalAccessException e) { + throw new CommandLineException("String constructor for option value type '" + clazz.getSimpleName() + + "' must be public.", e); + } catch (final InvocationTargetException e) { + throw new CommandLineException("Problem constructing " + clazz.getSimpleName() + + " from the string '" + s + "'.", e.getCause()); + } + } + + public String[] getArgv() { + return argv; + } + + protected static class OptionDefinitionByPrintOrderComparator implements Comparator { + + @Override + public int compare(OptionDefinition o1, OptionDefinition o2) { + return o1.printOrder - o2.printOrder; + } + } + + protected static final class OptionDefinition { + final Field field; + final String name; + final String shortName; + final String doc; + final boolean optional; + final boolean overridable; + final boolean isCollection; + final int minElements; + final int maxElements; + final int printOrder; + final String defaultValue; + final boolean isCommon; + boolean hasBeenSet = false; + boolean hasBeenSetFromOptionsFile = false; + boolean hasBeenSetFromParent = false; + final Set mutuallyExclusive; + + private OptionDefinition(final Field field, final String name, final String shortName, final String doc, + final boolean optional, final boolean overridable, boolean collection, final int minElements, + final int maxElements, final Object defaultValue, final boolean isCommon, + final String[] mutuallyExclusive, final int printOrder) { + this.field = field; + this.name = name.toUpperCase(); + this.shortName = shortName.toUpperCase(); + this.doc = doc; + this.optional = optional; + this.overridable = overridable; + isCollection = collection; + this.minElements = minElements; + this.maxElements = maxElements; + if (defaultValue != null) { + if (isCollection && ((Collection) defaultValue).isEmpty()) { + //treat empty collections the same as uninitialized primitive types + this.defaultValue = "null"; + } else { + //this is an intialized primitive type or a non-empty collection + this.defaultValue = defaultValue.toString(); + } + } else { + this.defaultValue = "null"; + } + this.isCommon = isCommon; + this.mutuallyExclusive = new HashSet(Arrays.asList(mutuallyExclusive)); + this.printOrder = printOrder; + } + } + + /** + * Holds a command-line argument that is destined for a child parser. Prefix has been stripped from name. + */ + private static final class ChildOptionArg { + final String name; + final String value; + final boolean fromFile; + final boolean precedenceSet; + + private ChildOptionArg(final String name, final String value, final boolean fromFile, final boolean precedenceSet) { + this.name = name; + this.value = value; + this.fromFile = fromFile; + this.precedenceSet = precedenceSet; + } + } + + /** + * The commandline used to run this program, including any default args that + * weren't necessarily specified. This is used for logging and debugging. + *

    + * NOTE: {@link #parseArguments(PrintStream, String[])} must be called before + * calling this method. + * + * @return The commandline, or null if {@link #parseArguments(PrintStream, String[])} + * hasn't yet been called, or didn't complete successfully. + */ + @Override + public String getCommandLine() { return commandLine; } + + /** + * This method is only needed when calling one of the public methods that doesn't take a messageStream argument. + */ + public void setMessageStream(final PrintStream messageStream) { + this.messageStream = messageStream; + } + + public Object getCallerOptions() { + return callerOptions; + } +} diff --git a/src/main/java/org/broadinstitute/barclay/argparser/PositionalArguments.java b/src/main/java/org/broadinstitute/barclay/argparser/PositionalArguments.java new file mode 100644 index 00000000..f94be0a8 --- /dev/null +++ b/src/main/java/org/broadinstitute/barclay/argparser/PositionalArguments.java @@ -0,0 +1,36 @@ +package org.broadinstitute.barclay.argparser; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Used to annotate which field of a CommandLineProgram should store parameters given at the + * command line which are not options. Fields with this annotation must be a Collection + * (and probably should be a List if order is important). + * If a command line call looks like "cmd option=foo x=y bar baz" the values "bar" and "baz" + * would be added to the collection with this annotation. The java type of the arguments + * will be inferred from the generic type of the collection. The type must be an enum or + * have a constructor with a single String parameter. + * + * @author Alec Wysoker + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.FIELD) +@Documented +public @interface PositionalArguments { + /** The minimum number of arguments required. */ + int minElements() default 0; + + /** The maximum number of arguments allowed. */ + int maxElements() default Integer.MAX_VALUE; + + /** + * Documentation for the command-line argument. Should appear when the + * --help argument is specified. + * @return Doc string associated with this command-line argument. + */ + String doc() default "Undocumented option"; +} diff --git a/src/main/java/org/broadinstitute/barclay/argparser/SpecialArgumentsCollection.java b/src/main/java/org/broadinstitute/barclay/argparser/SpecialArgumentsCollection.java new file mode 100644 index 00000000..f193654b --- /dev/null +++ b/src/main/java/org/broadinstitute/barclay/argparser/SpecialArgumentsCollection.java @@ -0,0 +1,26 @@ +package org.broadinstitute.barclay.argparser; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; + +/** + * This collection is for arguments that require special treatment by the arguments parser itself. + * It should not grow beyond a very short list. + */ +public final class SpecialArgumentsCollection { + public static final String HELP_FULLNAME = "help"; + public static final String VERSION_FULLNAME = "version"; + public static final String ARGUMENTS_FILE_FULLNAME = "arguments_file"; + private static final long serialVersionUID = 1L; + + @Argument(shortName = "h", fullName = HELP_FULLNAME, doc= "display the help message", special = true) + public boolean HELP = false; + + @Argument(fullName = VERSION_FULLNAME, doc="display the version number for this tool", special = true) + public boolean VERSION = false; + + @Argument(fullName = ARGUMENTS_FILE_FULLNAME, doc="read one or more arguments files and add them to the command line", optional = true, special = true) + public List ARGUMENTS_FILE = new ArrayList<>(); + +} diff --git a/src/main/java/org/broadinstitute/barclay/argparser/StrictBooleanConverter.java b/src/main/java/org/broadinstitute/barclay/argparser/StrictBooleanConverter.java new file mode 100644 index 00000000..c091ae84 --- /dev/null +++ b/src/main/java/org/broadinstitute/barclay/argparser/StrictBooleanConverter.java @@ -0,0 +1,27 @@ +package org.broadinstitute.barclay.argparser; + +import joptsimple.ValueConversionException; +import joptsimple.ValueConverter; + +/** + * converts values case insensitively matching T, True, F, or False to true or false + * throws {@link ValueConversionException} otherwise + */ +public final class StrictBooleanConverter implements ValueConverter { + public String convert( String value ) { + if ( value.equalsIgnoreCase("true") || value.equalsIgnoreCase("t")) { + return "true"; + } else if (value.equalsIgnoreCase("false") || value.equalsIgnoreCase("f")) { + return "false"; + } else { + throw new ValueConversionException(value + " does not match one of T|True|F|False"); + } + } + public final Class valueType() { + return String.class; + } + + public String valuePattern() { + return "[T|True|F|False]"; + } +} diff --git a/src/main/java/org/broadinstitute/barclay/utils/Utils.java b/src/main/java/org/broadinstitute/barclay/utils/Utils.java new file mode 100644 index 00000000..82694094 --- /dev/null +++ b/src/main/java/org/broadinstitute/barclay/utils/Utils.java @@ -0,0 +1,49 @@ +package org.broadinstitute.barclay.utils; + +import java.util.Collection; +import java.util.Iterator; +import java.util.function.Supplier; + +/** + * Utility classes used by the command line parsers. + */ +public class Utils { + /** + * Checks that an Object {@code object} is not null and returns the same object or throws an {@link IllegalArgumentException} + * @param object any Object + * @return the same object + * @throws IllegalArgumentException if a {@code o == null} + */ + public static T nonNull(final T object) { + return Utils.nonNull(object, "Null object is not allowed here."); + } + + /** + * Checks that an {@link Object} is not {@code null} and returns the same object or throws an {@link IllegalArgumentException} + * @param object any Object + * @param message the text message that would be passed to the exception thrown when {@code o == null}. + * @return the same object + * @throws IllegalArgumentException if a {@code o == null} + */ + public static T nonNull(final T object, final String message) { + if (object == null) { + throw new IllegalArgumentException(message); + } + return object; + } + + /** + * Checks that an {@link Object} is not {@code null} and returns the same object or throws an {@link IllegalArgumentException} + * @param object any Object + * @param message the text message that would be passed to the exception thrown when {@code o == null}. + * @return the same object + * @throws IllegalArgumentException if a {@code o == null} + */ + public static T nonNull(final T object, final Supplier message) { + if (object == null) { + throw new IllegalArgumentException(message.get()); + } + return object; + } + +} diff --git a/src/test/java/org/broadinstitute/barclay/argparser/CommandLineArgumentParserTest.java b/src/test/java/org/broadinstitute/barclay/argparser/CommandLineArgumentParserTest.java new file mode 100644 index 00000000..5ef5ecca --- /dev/null +++ b/src/test/java/org/broadinstitute/barclay/argparser/CommandLineArgumentParserTest.java @@ -0,0 +1,1044 @@ +package org.broadinstitute.barclay.argparser; + +import org.apache.commons.lang3.tuple.Pair; + +import org.testng.Assert; +import org.testng.annotations.DataProvider; +import org.testng.annotations.Test; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.PrintStream; +import java.io.PrintWriter; +import java.lang.reflect.Field; +import java.util.*; +import java.util.function.Consumer; + +public final class CommandLineArgumentParserTest { + enum FrobnicationFlavor { + FOO, BAR, BAZ + } + + @CommandLineProgramProperties( + summary = "Usage: frobnicate [arguments] input-file output-file\n\nRead input-file, frobnicate it, and write frobnicated results to output-file\n", + oneLineSummary = "Read input-file, frobnicate it, and write frobnicated results to output-file", + programGroup = TestProgramGroup.class + ) + class FrobnicateArguments { + @ArgumentCollection + SpecialArgumentsCollection specialArgs = new SpecialArgumentsCollection(); + + @PositionalArguments(minElements=2, maxElements=2) + public List positionalArguments = new ArrayList<>(); + + @Argument(shortName="T", doc="Frobnication threshold setting.") + public Integer FROBNICATION_THRESHOLD = 20; + + @Argument + public FrobnicationFlavor FROBNICATION_FLAVOR; + + @Argument(doc="Allowed shmiggle types.", optional = false) + public List SHMIGGLE_TYPE = new ArrayList<>(); + + @Argument + public Boolean TRUTHINESS = false; + } + + @CommandLineProgramProperties( + summary = "Usage: framistat [arguments]\n\nCompute the plebnick of the freebozzle.\n", + oneLineSummary = "ompute the plebnick of the freebozzle", + programGroup = TestProgramGroup.class + ) + class ArgumentsWithoutPositional { + public static final int DEFAULT_FROBNICATION_THRESHOLD = 20; + @Argument(shortName="T", doc="Frobnication threshold setting.") + public Integer FROBNICATION_THRESHOLD = DEFAULT_FROBNICATION_THRESHOLD; + + @Argument + public FrobnicationFlavor FROBNICATION_FLAVOR; + + @Argument(doc="Allowed shmiggle types.", optional = false) + public List SHMIGGLE_TYPE = new ArrayList<>(); + + @Argument + public Boolean TRUTHINESS; + } + + class MutexArguments { + @Argument(mutex={"M", "N", "Y", "Z"}) + public String A; + @Argument(mutex={"M", "N", "Y", "Z"}) + public String B; + @Argument(mutex={"A", "B", "Y", "Z"}) + public String M; + @Argument(mutex={"A", "B", "Y", "Z"}) + public String N; + @Argument(mutex={"A", "B", "M", "N"}) + public String Y; + @Argument(mutex={"A", "B", "M", "N"}) + public String Z; + + } + + @CommandLineProgramProperties( + summary = "[oscillation_frequency]\n\nResets oscillation frequency.\n", + oneLineSummary = "Reset oscillation frequency.", + programGroup = TestProgramGroup.class + ) + public class RequiredOnlyArguments { + @Argument(doc="Oscillation frequency.", optional = false) + public String OSCILLATION_FREQUENCY; + } + + @Test + public void testRequiredOnlyUsage() { + final RequiredOnlyArguments nr = new RequiredOnlyArguments(); + final CommandLineArgumentParser clp = new CommandLineArgumentParser(nr); + final String out = captureStderr(() -> clp.usage(System.err, false)); // without common args + final int reqIndex = out.indexOf("Required Arguments:"); + Assert.assertTrue(reqIndex > 0); + Assert.assertTrue(out.indexOf("Optional Arguments:", reqIndex) < 0); + } + + class AbbreviatableArgument{ + public static final String ARGUMENT_NAME = "longNameArgument"; + @Argument(fullName= ARGUMENT_NAME) + public boolean longNameArgument; + } + + @Test(expectedExceptions = CommandLineException.class) + public void testAbbreviationsAreRejected() { + final AbbreviatableArgument abrv = new AbbreviatableArgument(); + final CommandLineArgumentParser clp = new CommandLineArgumentParser(abrv); + //argument name is valid when it isn't abbreviated + Assert.assertTrue(clp.parseArguments(System.err, new String[]{"--" + AbbreviatableArgument.ARGUMENT_NAME})); + + //should throw when the abbreviated name is used + clp.parseArguments(System.err, new String[]{"--" + AbbreviatableArgument.ARGUMENT_NAME.substring(0,5)}); + } + + @CommandLineProgramProperties( + summary = "[oscillation_frequency]\n\nRecalibrates overthruster oscillation. \n", + oneLineSummary = "Recalibrates the overthruster.", + programGroup = TestProgramGroup.class + ) + public class OptionalOnlyArguments { + @Argument(doc="Oscillation frequency.", optional = true) + public String OSCILLATION_FREQUENCY = "20"; + } + + @Test + public void testOptionalOnlyUsage() { + final OptionalOnlyArguments oo = new OptionalOnlyArguments(); + final CommandLineArgumentParser clp = new CommandLineArgumentParser(oo); + final String out = captureStderr(() -> clp.usage(System.err, false)); // without common args + final int reqIndex = out.indexOf("Required Arguments:"); + Assert.assertTrue(reqIndex < 0); + Assert.assertTrue(out.indexOf("Optional Arguments:", reqIndex) > 0); + Assert.assertEquals(out.indexOf("Conditional Arguments:", reqIndex), -1); + } + + /** + * Validate the text emitted by a call to usage by ensuring that required arguments are + * emitted before optional ones. + */ + private void validateRequiredOptionalUsage(final CommandLineArgumentParser clp, final boolean withDefault) { + final String out = captureStderr(() -> clp.usage(System.err, withDefault)); // with common args + // Required arguments should appear before optional ones + final int reqIndex = out.indexOf("Required Arguments:"); + Assert.assertTrue(reqIndex > 0); + Assert.assertTrue(out.indexOf("Optional Arguments:", reqIndex) > 0); + Assert.assertEquals(out.indexOf("Conditional Arguments:", reqIndex), -1); + } + + @Test + public void testRequiredOptionalWithDefaultUsage() { + final FrobnicateArguments fo = new FrobnicateArguments(); + final CommandLineArgumentParser clp = new CommandLineArgumentParser(fo); + validateRequiredOptionalUsage(clp, true); // with common args + } + + @Test + public void testRequiredOptionalWithoutDefaultUsage() { + final FrobnicateArguments fo = new FrobnicateArguments(); + final CommandLineArgumentParser clp = new CommandLineArgumentParser(fo); + validateRequiredOptionalUsage(clp, false); // without common args + } + + @Test + public void testWithoutPositionalWithDefaultUsage() { + final ArgumentsWithoutPositional fo = new ArgumentsWithoutPositional(); + final CommandLineArgumentParser clp = new CommandLineArgumentParser(fo); + validateRequiredOptionalUsage(clp, true); // with common args + } + + @Test + public void testWithoutPositionalWithoutDefaultUsage() { + final ArgumentsWithoutPositional fo = new ArgumentsWithoutPositional(); + final CommandLineArgumentParser clp = new CommandLineArgumentParser(fo); + validateRequiredOptionalUsage(clp, false); // without commoon args + } + + @Test + public void testPositive() { + final String[] args = { + "-T","17", + "-FROBNICATION_FLAVOR","BAR", + "-TRUTHINESS", + "-SHMIGGLE_TYPE","shmiggle1", + "-SHMIGGLE_TYPE","shmiggle2", + "positional1", + "positional2", + }; + final FrobnicateArguments fo = new FrobnicateArguments(); + final CommandLineArgumentParser clp = new CommandLineArgumentParser(fo); + Assert.assertTrue(clp.parseArguments(System.err, args)); + Assert.assertEquals(fo.positionalArguments.size(), 2); + final File[] expectedPositionalArguments = { new File("positional1"), new File("positional2")}; + Assert.assertEquals(fo.positionalArguments.toArray(), expectedPositionalArguments); + Assert.assertEquals(fo.FROBNICATION_THRESHOLD.intValue(), 17); + Assert.assertEquals(fo.FROBNICATION_FLAVOR, FrobnicationFlavor.BAR); + Assert.assertEquals(fo.SHMIGGLE_TYPE.size(), 2); + final String[] expectedShmiggleTypes = {"shmiggle1", "shmiggle2"}; + Assert.assertEquals(fo.SHMIGGLE_TYPE.toArray(), expectedShmiggleTypes); + Assert.assertTrue(fo.TRUTHINESS); + } + + @Test + public void testGetCommandLine() { + final String[] args = { + "-T","17", + "-FROBNICATION_FLAVOR","BAR", + "-TRUTHINESS", + "-SHMIGGLE_TYPE","shmiggle1", + "-SHMIGGLE_TYPE","shmiggle2", + "positional1", + "positional2", + }; + final FrobnicateArguments fo = new FrobnicateArguments(); + final CommandLineArgumentParser clp = new CommandLineArgumentParser(fo); + Assert.assertTrue(clp.parseArguments(System.err, args)); + Assert.assertEquals(clp.getCommandLine(), + "org.broadinstitute.barclay.argparser.CommandLineArgumentParserTest$FrobnicateArguments " + + "positional1 positional2 --FROBNICATION_THRESHOLD 17 --FROBNICATION_FLAVOR BAR " + + "--SHMIGGLE_TYPE shmiggle1 --SHMIGGLE_TYPE shmiggle2 --TRUTHINESS true --help false " + + "--version false"); + } + + private static class WithSensitiveValues { + + @Argument(sensitive = true) + public String secretValue; + + @Argument + public String openValue; + } + + @Test + public void testGetCommandLineWithSensitiveArgument(){ + final String supersecret = "supersecret"; + final String unclassified = "unclassified"; + final String[] args = { + "--secretValue", supersecret, + "--openValue", unclassified + }; + final WithSensitiveValues sv = new WithSensitiveValues(); + final CommandLineArgumentParser clp = new CommandLineArgumentParser(sv); + Assert.assertTrue(clp.parseArguments(System.err, args)); + + final String commandLine = clp.getCommandLine(); + + Assert.assertTrue(commandLine.contains(unclassified)); + Assert.assertFalse(commandLine.contains(supersecret)); + + Assert.assertEquals(sv.openValue, unclassified); + Assert.assertEquals(sv.secretValue, supersecret); + } + + @Test + public void testDefault() { + final String[] args = { + "--FROBNICATION_FLAVOR","BAR", + "--TRUTHINESS", + "--SHMIGGLE_TYPE","shmiggle1", + "--SHMIGGLE_TYPE","shmiggle2", + "positional1", + "positional2", + }; + final FrobnicateArguments fo = new FrobnicateArguments(); + final CommandLineArgumentParser clp = new CommandLineArgumentParser(fo); + Assert.assertTrue(clp.parseArguments(System.err, args)); + Assert.assertEquals(fo.FROBNICATION_THRESHOLD.intValue(), 20); + } + + @Test(expectedExceptions = CommandLineException.MissingArgument.class) + public void testMissingRequiredArgument() { + final String[] args = { + "--TRUTHINESS","False", + "--SHMIGGLE_TYPE","shmiggle1", + "--SHMIGGLE_TYPE","shmiggle2", + "positional1", + "positional2", + }; + final FrobnicateArguments fo = new FrobnicateArguments(); + final CommandLineArgumentParser clp = new CommandLineArgumentParser(fo); + clp.parseArguments(System.err, args); + } + + class CollectionRequired{ + @Argument(optional = false) + List ints; + } + + @Test(expectedExceptions = CommandLineException.MissingArgument.class) + public void testMissingRequiredCollectionArgument(){ + final String[] args = {}; + final CollectionRequired cr = new CollectionRequired(); + final CommandLineArgumentParser clp = new CommandLineArgumentParser(cr); + clp.parseArguments(System.err, args); + } + + @Test( expectedExceptions = CommandLineException.BadArgumentValue.class) + public void testBadValue() { + final String[] args = { + "--FROBNICATION_THRESHOLD","ABC", + "--FROBNICATION_FLAVOR","BAR", + "--TRUTHINESS","False", + "--SHMIGGLE_TYPE","shmiggle1", + "--SHMIGGLE_TYPE","shmiggle2", + "positional1", + "positional2", + }; + final FrobnicateArguments fo = new FrobnicateArguments(); + final CommandLineArgumentParser clp = new CommandLineArgumentParser(fo); + clp.parseArguments(System.err, args); + } + + @Test(expectedExceptions = CommandLineException.BadArgumentValue.class) + public void testBadEnumValue() { + final String[] args = { + "--FROBNICATION_FLAVOR","HiMom", + "--TRUTHINESS","False", + "--SHMIGGLE_TYPE","shmiggle1", + "--SHMIGGLE_TYPE","shmiggle2", + "positional1", + "positional2", + }; + final FrobnicateArguments fo = new FrobnicateArguments(); + final CommandLineArgumentParser clp = new CommandLineArgumentParser(fo); + clp.parseArguments(System.err, args); + } + + @Test(expectedExceptions = CommandLineException.MissingArgument.class) + public void testNotEnoughOfListArgument() { + final String[] args = { + "--FROBNICATION_FLAVOR","BAR", + "--TRUTHINESS","False", + "positional1", + "positional2", + }; + final FrobnicateArguments fo = new FrobnicateArguments(); + final CommandLineArgumentParser clp = new CommandLineArgumentParser(fo); + clp.parseArguments(System.err, args); + } + + @Test(expectedExceptions = CommandLineException.class) + public void testTooManyPositional() { + final String[] args = { + "--FROBNICATION_FLAVOR","BAR", + "--TRUTHINESS","False", + "--SHMIGGLE_TYPE","shmiggle1", + "--SHMIGGLE_TYPE","shmiggle2", + "positional1", + "positional2", + "positional3", + }; + final FrobnicateArguments fo = new FrobnicateArguments(); + final CommandLineArgumentParser clp = new CommandLineArgumentParser(fo); + clp.parseArguments(System.err, args); + } + + @Test(expectedExceptions = CommandLineException.MissingArgument.class) + public void testNotEnoughPositional() { + final String[] args = { + "--FROBNICATION_FLAVOR","BAR", + "--TRUTHINESS","False", + "--SHMIGGLE_TYPE","shmiggle1", + "--SHMIGGLE_TYPE","shmiggle2", + }; + final FrobnicateArguments fo = new FrobnicateArguments(); + final CommandLineArgumentParser clp = new CommandLineArgumentParser(fo); + clp.parseArguments(System.err, args); + } + + @Test( expectedExceptions = CommandLineException.class) + public void testUnexpectedPositional() { + final String[] args = { + "--T","17", + "--FROBNICATION_FLAVOR","BAR", + "--TRUTHINESS","False", + "--SHMIGGLE_TYPE","shmiggle1", + "--SHMIGGLE_TYPE","shmiggle2", + "positional" + }; + final ArgumentsWithoutPositional fo = new ArgumentsWithoutPositional(); + final CommandLineArgumentParser clp = new CommandLineArgumentParser(fo); + clp.parseArguments(System.err, args); + } + + @Test(expectedExceptions = CommandLineException.class) + public void testArgumentUseClash() { + final String[] args = { + "--FROBNICATION_FLAVOR", "BAR", + "--FROBNICATION_FLAVOR", "BAZ", + "--SHMIGGLE_TYPE", "shmiggle1", + "positional1", + "positional2", + }; + final FrobnicateArguments fo = new FrobnicateArguments(); + final CommandLineArgumentParser clp = new CommandLineArgumentParser(fo); + clp.parseArguments(System.err, args); + } + + @Test + public void testArgumentsFile() throws Exception { + final File argumentsFile = File.createTempFile("clp.", ".arguments"); + argumentsFile.deleteOnExit(); + try (final PrintWriter writer = new PrintWriter(argumentsFile)) { + writer.println("-T 18"); + writer.println("--TRUTHINESS"); + writer.println("--SHMIGGLE_TYPE shmiggle0"); + writer.println("--" + SpecialArgumentsCollection.ARGUMENTS_FILE_FULLNAME + " " + argumentsFile.getPath()); + //writer.println("--STRANGE_ARGUMENT shmiggle0"); + } + final String[] args = { + "--"+SpecialArgumentsCollection.ARGUMENTS_FILE_FULLNAME, argumentsFile.getPath(), + // Multiple arguments files are allowed + "--"+SpecialArgumentsCollection.ARGUMENTS_FILE_FULLNAME, argumentsFile.getPath(), + "--FROBNICATION_FLAVOR","BAR", + "--TRUTHINESS", + "--SHMIGGLE_TYPE","shmiggle0", + "--SHMIGGLE_TYPE","shmiggle1", + "positional1", + "positional2", + }; + final FrobnicateArguments fo = new FrobnicateArguments(); + final CommandLineArgumentParser clp = new CommandLineArgumentParser(fo); + Assert.assertTrue(clp.parseArguments(System.err, args)); + Assert.assertEquals(fo.positionalArguments.size(), 2); + final File[] expectedPositionalArguments = { new File("positional1"), new File("positional2")}; + Assert.assertEquals(fo.positionalArguments.toArray(), expectedPositionalArguments); + Assert.assertEquals(fo.FROBNICATION_THRESHOLD.intValue(), 18); + Assert.assertEquals(fo.FROBNICATION_FLAVOR, FrobnicationFlavor.BAR); + Assert.assertEquals(fo.SHMIGGLE_TYPE.size(), 3); + final String[] expectedShmiggleTypes = {"shmiggle0", "shmiggle0", "shmiggle1"}; + Assert.assertEquals(fo.SHMIGGLE_TYPE.toArray(), expectedShmiggleTypes); + Assert.assertTrue(fo.TRUTHINESS); + } + + + /** + * In an arguments file, should not be allowed to override an argument set on the command line + * @throws Exception + */ + @Test( expectedExceptions = CommandLineException.class) + public void testArgumentsFileWithDisallowedOverride() throws Exception { + final File argumentsFile = File.createTempFile("clp.", ".arguments"); + argumentsFile.deleteOnExit(); + try (final PrintWriter writer = new PrintWriter(argumentsFile)) { + writer.println("--T 18"); + } + final String[] args = { + "--T","17", + "--"+SpecialArgumentsCollection.ARGUMENTS_FILE_FULLNAME ,argumentsFile.getPath() + }; + final FrobnicateArguments fo = new FrobnicateArguments(); + final CommandLineArgumentParser clp = new CommandLineArgumentParser(fo); + clp.parseArguments(System.err, args); + } + + @DataProvider(name="failingMutexScenarios") + public Object[][] failingMutexScenarios() { + return new Object[][] { + { "no args", new String[0], false }, + { "1 of group required", new String[] {"-A","1"}, false }, + { "mutex", new String[] {"-A","1", "-Y","3"}, false }, + { "mega mutex", new String[] {"-A","1", "-B","2", "-Y","3", "-Z","1", "-M","2", "-N","3"}, false } + }; + } + + @Test + public void passingMutexCheck(){ + final MutexArguments o = new MutexArguments(); + final CommandLineArgumentParser clp = new CommandLineArgumentParser(o); + Assert.assertTrue(clp.parseArguments(System.err, new String[]{"-A", "1", "-B", "2"})); + } + + @Test(dataProvider="failingMutexScenarios", expectedExceptions = CommandLineException.class) + public void testFailingMutex(final String testName, final String[] args, final boolean expected) { + final MutexArguments o = new MutexArguments(); + final CommandLineArgumentParser clp = new CommandLineArgumentParser(o); + clp.parseArguments(System.err, args); + } + + class UninitializedCollectionArguments { + @Argument + public List LIST; + @Argument + public ArrayList ARRAY_LIST; + @Argument + public HashSet HASH_SET; + @PositionalArguments + public Collection COLLECTION; + + } + + @Test + public void testUninitializedCollections() { + final UninitializedCollectionArguments o = new UninitializedCollectionArguments(); + final CommandLineArgumentParser clp = new CommandLineArgumentParser(o); + final String[] args = {"--LIST","L1", "--LIST","L2", "--ARRAY_LIST","S1", "--HASH_SET","HS1", "P1", "P2"}; + Assert.assertTrue(clp.parseArguments(System.err, args)); + Assert.assertEquals(o.LIST.size(), 2); + Assert.assertEquals(o.ARRAY_LIST.size(), 1); + Assert.assertEquals(o.HASH_SET.size(), 1); + Assert.assertEquals(o.COLLECTION.size(), 2); + } + + class UninitializedCollectionThatCannotBeAutoInitializedArguments { + @Argument + public Set SET; + } + + @Test(expectedExceptions = CommandLineException.CommandLineParserInternalException.class) + public void testCollectionThatCannotBeAutoInitialized() { + final UninitializedCollectionThatCannotBeAutoInitializedArguments o = new UninitializedCollectionThatCannotBeAutoInitializedArguments(); + new CommandLineArgumentParser(o); + } + + class CollectionWithDefaultValuesArguments { + + @Argument + public List LIST = new ArrayList<>(); + + public List makeList(final String... list) { + final List result = new ArrayList<>(); + Collections.addAll(result, list); + return result; + } + + } + + @Test + public void testClearDefaultValuesFromListArgument() { + final CollectionWithDefaultValuesArguments o = new CollectionWithDefaultValuesArguments(); + o.LIST = o.makeList("foo", "bar"); + final CommandLineArgumentParser clp = new CommandLineArgumentParser(o); + final String[] args = {"--LIST","null"}; + Assert.assertTrue(clp.parseArguments(System.err, args)); + Assert.assertEquals(o.LIST.size(), 0); + } + + @Test + public void testClearDefaultValuesFromListArgumentAndAddNew() { + final CollectionWithDefaultValuesArguments o = new CollectionWithDefaultValuesArguments(); + o.LIST = o.makeList("foo", "bar"); + final CommandLineArgumentParser clp = new CommandLineArgumentParser(o); + final String[] args = {"--LIST","null", "--LIST","baz", "--LIST","frob"}; + Assert.assertTrue(clp.parseArguments(System.err, args)); + Assert.assertEquals(o.LIST, o.makeList("baz", "frob")); + } + + @Test + public void testDefaultValuesListArgument() { + final CollectionWithDefaultValuesArguments o = new CollectionWithDefaultValuesArguments(); + o.LIST = o.makeList("foo", "bar"); + final CommandLineArgumentParser clp = new CommandLineArgumentParser(o); + final String[] args = {"--LIST","baz", "--LIST","frob"}; + Assert.assertTrue(clp.parseArguments(System.err, args)); + Assert.assertEquals(o.LIST, o.makeList("foo", "bar", "baz", "frob")); + } + + + @Test + public void testFlagNoArgument(){ + final BooleanFlags o = new BooleanFlags(); + final CommandLineArgumentParser clp = new CommandLineArgumentParser(o); + Assert.assertTrue(clp.parseArguments(System.err, new String[]{"--flag1"})); + Assert.assertTrue(o.flag1); + } + + @Test + public void testFlagsWithArguments(){ + final BooleanFlags o = new BooleanFlags(); + final CommandLineArgumentParser clp = new CommandLineArgumentParser(o); + Assert.assertTrue(clp.parseArguments(System.err, new String[]{"--flag1", "false", "--flag2", "false"})); + Assert.assertFalse(o.flag1); + Assert.assertFalse(o.flag2); + } + + class ArgsCollection { + @Argument(fullName = "arg1") + public int Arg1; + } + + class ArgsCollectionHaver{ + + public ArgsCollectionHaver(){} + + @ArgumentCollection + public ArgsCollection default_args = new ArgsCollection(); + + @Argument(fullName = "somenumber",shortName = "n") + public int someNumber = 0; + } + + @Test + public void testArgumentCollection(){ + final ArgsCollectionHaver o = new ArgsCollectionHaver(); + final CommandLineArgumentParser clp = new CommandLineArgumentParser(o); + + String[] args = {"--arg1", "42", "--somenumber", "12"}; + Assert.assertTrue(clp.parseArguments(System.err, args)); + Assert.assertEquals(o.someNumber, 12); + Assert.assertEquals(o.default_args.Arg1, 42); + + } + + class BooleanFlags{ + @Argument + public Boolean flag1 = false; + + @Argument + public boolean flag2 = true; + + @Argument + public boolean flag3 = false; + + @Argument(mutex="flag1") + public boolean antiflag1 = false; + + @ArgumentCollection + SpecialArgumentsCollection special = new SpecialArgumentsCollection(); + } + + @Test + public void testCombinationOfFlags(){ + final BooleanFlags o = new BooleanFlags(); + final CommandLineArgumentParser clp = new CommandLineArgumentParser(o); + + clp.parseArguments(System.err, new String[]{"--flag1", "false", "--flag2"}); + Assert.assertFalse(o.flag1); + Assert.assertTrue(o.flag2); + Assert.assertFalse(o.flag3); + } + + class WithBadField{ + @Argument + @ArgumentCollection + public boolean badfield; + } + + @Test(expectedExceptions = CommandLineException.CommandLineParserInternalException.class) + public void testBadFieldCausesException(){ + WithBadField o = new WithBadField(); + final CommandLineArgumentParser clp = new CommandLineArgumentParser(o); + } + + class PrivateArgument{ + @Argument + private boolean privateArgument = false; + + @Argument(optional = true) + private List privateCollection = new ArrayList<>(); + + @ArgumentCollection + private BooleanFlags booleanFlags= new BooleanFlags(); + + @PositionalArguments() + List positionals = new ArrayList<>(); + } + + @Test + public void testFlagWithPositionalFollowing(){ + PrivateArgument o = new PrivateArgument(); + final CommandLineArgumentParser clp = new CommandLineArgumentParser(o); + Assert.assertTrue(clp.parseArguments(System.err, new String[]{"--flag1","1","2" })); + Assert.assertTrue(o.booleanFlags.flag1); + Assert.assertEquals(o.positionals, Arrays.asList(1, 2)); + } + + @Test + public void testPrivateArgument(){ + PrivateArgument o = new PrivateArgument(); + final CommandLineArgumentParser clp = new CommandLineArgumentParser(o); + Assert.assertTrue(clp.parseArguments(System.err, new String[]{"--privateArgument", + "--privateCollection", "1", "--privateCollection", "2", "--flag1"})); + Assert.assertTrue(o.privateArgument); + Assert.assertEquals(o.privateCollection, Arrays.asList(1,2)); + Assert.assertTrue(o.booleanFlags.flag1); + } + + /** + * Test that the special flag --version is handled correctly + * (no blowup) + */ + @Test + public void testVersionSpecialFlag(){ + final BooleanFlags o = new BooleanFlags(); + final CommandLineArgumentParser clp = new CommandLineArgumentParser(o); + + final String[] versionArgs = {"--" + SpecialArgumentsCollection.VERSION_FULLNAME}; + String out = captureStderr(() -> { + Assert.assertFalse(clp.parseArguments(System.err, versionArgs)); + }); + Assert.assertTrue(out.contains("Version:")); + + Assert.assertTrue(clp.parseArguments(System.err, new String[]{"--version","false"})); + Assert.assertFalse(clp.parseArguments(System.err, new String[]{"--version", "true"})); + } + + /** + * Test that the special flag --help is handled correctly + * (no blowup) + */ + @Test + public void testHelp(){ + final BooleanFlags o = new BooleanFlags(); + final CommandLineArgumentParser clp = new CommandLineArgumentParser(o); + + final String[] versionArgs = {"--" + SpecialArgumentsCollection.HELP_FULLNAME}; + String out = captureStderr(() -> { + Assert.assertFalse(clp.parseArguments(System.err, versionArgs)); + }); + Assert.assertTrue(out.contains("USAGE:")); + + Assert.assertTrue(clp.parseArguments(System.err, new String[]{"--help","false"})); + Assert.assertFalse(clp.parseArguments(System.err, new String[]{"--help", "true"})); + } + + class NameCollision{ + @ArgumentCollection + public ArgsCollection argsCollection = new ArgsCollection(); + + //this arg name collides with one in ArgsCollection + @Argument(fullName = "arg1") + public int anArg; + } + + @Test(expectedExceptions = CommandLineException.CommandLineParserInternalException.class) + public void testArgumentNameCollision(){ + final NameCollision collides = new NameCollision(); + final CommandLineArgumentParser clp = new CommandLineArgumentParser(collides); + + clp.parseArguments(System.err, new String[]{"--arg1", "101"}); + } + /** + * captures {@link System#err} while runnable is executing + * @param runnable a code block to execute + * @return everything written to {@link System#err} by runnable + */ + public static String captureStderr(Runnable runnable){ + return captureSystemStream(runnable, System.err, System::setErr); + } + + private static String captureSystemStream(Runnable runnable, PrintStream stream, Consumer setter){ + ByteArrayOutputStream out = new ByteArrayOutputStream(); + setter.accept(new PrintStream(out)); + try { + runnable.run(); + } finally{ + setter.accept(stream); + } + return out.toString(); + } + + /*************************************************************************************** + * Start of tests and helper classes for CommandLineParser.gatherArgumentValuesOfType() + ***************************************************************************************/ + + /** + * Classes and argument collections for use with CommandLineParser.gatherArgumentValuesOfType() tests below. + * + * Structured to ensure that we test support for: + * + * -distinguishing between arguments of the target type, and arguments not of the target type + * -distinguishing between annotated and unannotated fields of the target type + * -gathering arguments that are a subtype of the target type + * -gathering multi-valued arguments of the target type within Collection types + * -gathering arguments of the target type that are not specified on the command line + * -gathering arguments of the target type from superclasses of our tool + * -gathering arguments of the target type from argument collections + * -gathering arguments when the target type is itself a parameterized type (eg., FeatureInput) + */ + + private static class GatherArgumentValuesTestSourceParent { + @Argument(fullName = "parentSuperTypeTarget", shortName = "parentSuperTypeTarget", doc = "") + private GatherArgumentValuesTargetSuperType parentSuperTypeTarget; + + @Argument(fullName = "parentSubTypeTarget", shortName = "parentSubTypeTarget", doc = "") + private GatherArgumentValuesTargetSubType parentSubTypeTarget; + + @Argument(fullName = "parentListSuperTypeTarget", shortName = "parentListSuperTypeTarget", doc = "") + private List parentListSuperTypeTarget; + + @Argument(fullName = "parentListSubTypeTarget", shortName = "parentListSubTypeTarget", doc = "") + private List parentListSubTypeTarget; + + @Argument(fullName = "uninitializedParentTarget", shortName = "uninitializedParentTarget", optional = true, doc = "") + private GatherArgumentValuesTargetSuperType uninitializedParentTarget; + + @Argument(fullName = "parentNonTargetArgument", shortName = "parentNonTargetArgument", doc = "") + private int parentNonTargetArgument; + + private GatherArgumentValuesTargetSuperType parentUnannotatedTarget; + + @ArgumentCollection + private GatherArgumentValuesTestSourceParentCollection parentCollection = new GatherArgumentValuesTestSourceParentCollection(); + } + + private static class GatherArgumentValuesTestSourceChild extends GatherArgumentValuesTestSourceParent { + @Argument(fullName = "childSuperTypeTarget", shortName = "childSuperTypeTarget", doc = "") + private GatherArgumentValuesTargetSuperType childSuperTypeTarget; + + @Argument(fullName = "childSubTypeTarget", shortName = "childSubTypeTarget", doc = "") + private GatherArgumentValuesTargetSubType childSubTypeTarget; + + @Argument(fullName = "childListSuperTypeTarget", shortName = "childListSuperTypeTarget", doc = "") + private List childListSuperTypeTarget; + + @Argument(fullName = "childListSubTypeTarget", shortName = "childListSubTypeTarget", doc = "") + private List childListSubTypeTarget; + + @Argument(fullName = "uninitializedChildTarget", shortName = "uninitializedChildTarget", optional = true, doc = "") + private GatherArgumentValuesTargetSuperType uninitializedChildTarget; + + @Argument(fullName = "uninitializedChildListTarget", shortName = "uninitializedChildListTarget", optional = true, doc = "") + private List uninitializedChildListTarget; + + @Argument(fullName = "childNonTargetArgument", shortName = "childNonTargetArgument", doc = "") + private int childNonTargetArgument; + + @Argument(fullName = "childNonTargetListArgument", shortName = "childNonTargetListArgument", doc = "") + private List childNonTargetListArgument; + + private GatherArgumentValuesTargetSuperType childUnannotatedTarget; + + @ArgumentCollection + private GatherArgumentValuesTestSourceChildCollection childCollection = new GatherArgumentValuesTestSourceChildCollection(); + } + + private static class GatherArgumentValuesTestSourceParentCollection { + private static final long serialVersionUID = 1L; + + @Argument(fullName = "parentCollectionSuperTypeTarget", shortName = "parentCollectionSuperTypeTarget", doc = "") + private GatherArgumentValuesTargetSuperType parentCollectionSuperTypeTarget; + + @Argument(fullName = "parentCollectionSubTypeTarget", shortName = "parentCollectionSubTypeTarget", doc = "") + private GatherArgumentValuesTargetSubType parentCollectionSubTypeTarget; + + @Argument(fullName = "uninitializedParentCollectionTarget", shortName = "uninitializedParentCollectionTarget", optional = true, doc = "") + private GatherArgumentValuesTargetSuperType uninitializedParentCollectionTarget; + + @Argument(fullName = "parentCollectionNonTargetArgument", shortName = "parentCollectionNonTargetArgument", doc = "") + private int parentCollectionNonTargetArgument; + + private GatherArgumentValuesTargetSuperType parentCollectionUnannotatedTarget; + } + + private static class GatherArgumentValuesTestSourceChildCollection { + private static final long serialVersionUID = 1L; + + @Argument(fullName = "childCollectionSuperTypeTarget", shortName = "childCollectionSuperTypeTarget", doc = "") + private GatherArgumentValuesTargetSuperType childCollectionSuperTypeTarget; + + @Argument(fullName = "childCollectionSubTypeTarget", shortName = "childCollectionSubTypeTarget", doc = "") + private GatherArgumentValuesTargetSubType childCollectionSubTypeTarget; + + @Argument(fullName = "childCollectionListSuperTypeTarget", shortName = "childCollectionListSuperTypeTarget", doc = "") + private List childCollectionListSuperTypeTarget; + + @Argument(fullName = "uninitializedChildCollectionTarget", shortName = "uninitializedChildCollectionTarget", optional = true, doc = "") + private GatherArgumentValuesTargetSuperType uninitializedChildCollectionTarget; + + @Argument(fullName = "childCollectionNonTargetArgument", shortName = "childCollectionNonTargetArgument", doc = "") + private int childCollectionNonTargetArgument; + + private GatherArgumentValuesTargetSuperType childCollectionUnannotatedTarget; + } + + /** + * Our tests will search for argument values of this type, subtypes of this type, and Collections of + * this type or its subtypes. Has a String constructor so that the argument parsing system can correctly + * initialize it. + */ + private static class GatherArgumentValuesTargetSuperType { + private String value; + + public GatherArgumentValuesTargetSuperType( String s ) { + value = s; + } + + public String getValue() { + return value; + } + } + + private static class GatherArgumentValuesTargetSubType extends GatherArgumentValuesTargetSuperType { + public GatherArgumentValuesTargetSubType( String s ) { + super(s); + } + } + + @DataProvider(name = "gatherArgumentValuesOfTypeDataProvider") + public Object[][] gatherArgumentValuesOfTypeDataProvider() { + // Non-Collection arguments of the target type + final List targetScalarArguments = Arrays.asList("childSuperTypeTarget", "childSubTypeTarget", + "parentSuperTypeTarget", "parentSubTypeTarget", + "childCollectionSuperTypeTarget", "childCollectionSubTypeTarget", + "parentCollectionSuperTypeTarget", "parentCollectionSubTypeTarget"); + // Collection arguments of the target type + final List targetListArguments = Arrays.asList("childListSuperTypeTarget", "childListSubTypeTarget", + "parentListSuperTypeTarget", "parentListSubTypeTarget", + "childCollectionListSuperTypeTarget"); + // Arguments of the target type that we won't specify on our command line + final List uninitializedTargetArguments = Arrays.asList("uninitializedChildTarget", "uninitializedChildListTarget", + "uninitializedParentTarget", "uninitializedChildCollectionTarget", + "uninitializedParentCollectionTarget"); + // Arguments not of the target type + final List nonTargetArguments = Arrays.asList("childNonTargetArgument", "parentNonTargetArgument", + "childCollectionNonTargetArgument", "parentCollectionNonTargetArgument", + "childNonTargetListArgument"); + + List commandLineArguments = new ArrayList<>(); + List> sortedExpectedGatheredValues = new ArrayList<>(); + + for ( String targetScalarArgument : targetScalarArguments ) { + final String argumentValue = targetScalarArgument + "Value"; + + commandLineArguments.add("--" + targetScalarArgument); + commandLineArguments.add(argumentValue); + sortedExpectedGatheredValues.add(Pair.of(targetScalarArgument, argumentValue)); + } + + // Give each list argument multiple values + for ( String targetListArgument : targetListArguments ) { + for ( int argumentNum = 1; argumentNum <= 3; ++argumentNum ) { + final String argumentValue = targetListArgument + "Value" + argumentNum; + + commandLineArguments.add("--" + targetListArgument); + commandLineArguments.add(argumentValue); + sortedExpectedGatheredValues.add(Pair.of(targetListArgument, argumentValue)); + } + } + + // Make sure the uninitialized args of the target type not included on the command line are + // represented in the expected output + for ( String uninitializedTargetArgument : uninitializedTargetArguments ) { + sortedExpectedGatheredValues.add(Pair.of(uninitializedTargetArgument, null)); + } + + // The non-target args are all of type int, so give them an arbitrary int value on the command line. + // These should not be gathered at all, so are not added to the expected output. + for ( String nonTargetArgument : nonTargetArguments ) { + commandLineArguments.add("--" + nonTargetArgument); + commandLineArguments.add("1"); + } + + Collections.sort(sortedExpectedGatheredValues); + + return new Object[][] {{ + commandLineArguments, sortedExpectedGatheredValues + }}; + } + + @Test(dataProvider = "gatherArgumentValuesOfTypeDataProvider") + public void testGatherArgumentValuesOfType( final List commandLineArguments, final List> sortedExpectedGatheredValues ) { + GatherArgumentValuesTestSourceChild argumentSource = new GatherArgumentValuesTestSourceChild(); + + // Parse the command line, and inject values into our test instance + CommandLineArgumentParser clp = new CommandLineArgumentParser(argumentSource); + clp.parseArguments(System.err, commandLineArguments.toArray(new String[commandLineArguments.size()])); + + // Gather all argument values of type GatherArgumentValuesTargetSuperType (or Collection), + // including subtypes. + List> gatheredArguments = + CommandLineParser.gatherArgumentValuesOfType(GatherArgumentValuesTargetSuperType.class, argumentSource); + + // Make sure we gathered the expected number of argument values + Assert.assertEquals(gatheredArguments.size(), sortedExpectedGatheredValues.size(), "Gathered the wrong number of arguments"); + + // Make sure actual gathered argument values match expected values + List> sortedActualGatheredArgumentValues = new ArrayList<>(); + for ( Pair gatheredArgument : gatheredArguments ) { + Assert.assertNotNull(gatheredArgument.getKey().getAnnotation(Argument.class), "Gathered argument is not annotated with an @Argument annotation"); + + String argumentName = gatheredArgument.getKey().getAnnotation(Argument.class).fullName(); + GatherArgumentValuesTargetSuperType argumentValue = gatheredArgument.getValue(); + + sortedActualGatheredArgumentValues.add(Pair.of(argumentName, argumentValue != null ? argumentValue.getValue() : null)); + } + Collections.sort(sortedActualGatheredArgumentValues); + + Assert.assertEquals(sortedActualGatheredArgumentValues, sortedExpectedGatheredValues, + "One or more gathered argument values not correct"); + + } + + /** + * Nonsensical parameterized class, just to ensure that CommandLineParser.gatherArgumentValuesOfType() + * can gather argument values of a generic type + * + * @param meaningless type parameter + */ + private static class GatherArgumentValuesParameterizedTargetType { + private String value; + private T foo; + + public GatherArgumentValuesParameterizedTargetType( String s ) { + value = s; + foo = null; + } + + public String getValue() { + return value; + } + } + + private static class GatherArgumentValuesParameterizedTypeSource { + @Argument(fullName = "parameterizedTypeArgument", shortName = "parameterizedTypeArgument", doc = "") + private GatherArgumentValuesParameterizedTargetType parameterizedTypeArgument; + + @Argument(fullName = "parameterizedTypeListArgument", shortName = "parameterizedTypeListArgument", doc = "") + private List> parameterizedTypeListArgument; + } + + @Test + @SuppressWarnings("rawtypes") + public void testGatherArgumentValuesOfTypeWithParameterizedType() { + GatherArgumentValuesParameterizedTypeSource argumentSource = new GatherArgumentValuesParameterizedTypeSource(); + + // Parse the command line, and inject values into our test instance + CommandLineArgumentParser clp = new CommandLineArgumentParser(argumentSource); + clp.parseArguments(System.err, new String[]{"--parameterizedTypeArgument", "parameterizedTypeArgumentValue", + "--parameterizedTypeListArgument", "parameterizedTypeListArgumentValue"}); + + // Gather argument values of the raw type GatherArgumentValuesParameterizedTargetType, and make + // sure that we match fully-parameterized declarations + List> gatheredArguments = + CommandLineParser.gatherArgumentValuesOfType(GatherArgumentValuesParameterizedTargetType.class, argumentSource); + + Assert.assertEquals(gatheredArguments.size(), 2, "Wrong number of arguments gathered"); + + Assert.assertNotNull(gatheredArguments.get(0).getKey().getAnnotation(Argument.class), "Gathered argument is not annotated with an @Argument annotation"); + Assert.assertEquals(gatheredArguments.get(0).getKey().getAnnotation(Argument.class).fullName(), "parameterizedTypeArgument", "Wrong argument gathered"); + Assert.assertEquals(gatheredArguments.get(0).getValue().getValue(), "parameterizedTypeArgumentValue", "Wrong value for gathered argument"); + Assert.assertNotNull(gatheredArguments.get(1).getKey().getAnnotation(Argument.class), "Gathered argument is not annotated with an @Argument annotation"); + Assert.assertEquals(gatheredArguments.get(1).getKey().getAnnotation(Argument.class).fullName(), "parameterizedTypeListArgument", "Wrong argument gathered"); + Assert.assertEquals(gatheredArguments.get(1).getValue().getValue(), "parameterizedTypeListArgumentValue", "Wrong value for gathered argument"); + } + + /*************************************************************************************** + * End of tests and helper classes for CommandLineParser.gatherArgumentValuesOfType() + ***************************************************************************************/ +} diff --git a/src/test/java/org/broadinstitute/barclay/argparser/CommandLinePluginUnitTest.java b/src/test/java/org/broadinstitute/barclay/argparser/CommandLinePluginUnitTest.java new file mode 100644 index 00000000..32c6faec --- /dev/null +++ b/src/test/java/org/broadinstitute/barclay/argparser/CommandLinePluginUnitTest.java @@ -0,0 +1,413 @@ +package org.broadinstitute.barclay.argparser; + +import org.testng.Assert; +import org.testng.annotations.DataProvider; +import org.testng.annotations.Test; + +import java.util.*; +import java.util.function.Predicate; + +/** + * Test command line parser plugin functionality. + */ +public class CommandLinePluginUnitTest { + + public static class TestPluginBase { + } + + public static class TestPluginWithOptionalArg extends TestPluginBase { + public static final String optionalArgName = "optionalStringArg"; + + @Argument(fullName=optionalArgName, optional=true) + String optionalArg; + } + + public static class TestPluginWithRequiredArg extends TestPluginBase { + public static final String requiredArgName = "requiredStringArg"; + + @Argument(fullName=requiredArgName, optional=false) + String requiredArg; + } + + public static class TestPlugin extends TestPluginBase { + final static String argumentName = "argumentForTestPlugin"; + @Argument(fullName = argumentName, optional=true) + Integer argumentForTestPlugin; + } + + public static class TestPluginDescriptor extends CommandLinePluginDescriptor { + + private static final String pluginPackageName = "org.broadinstitute.barclay.argparser"; + private final Class pluginBaseClass = TestPluginBase.class; + + public static final String testPluginArgumentName = "testPlugin"; + + @Argument(fullName = testPluginArgumentName, optional=true) + public final List userPluginNames = new ArrayList<>(); // preserve order + + // Map of plugin (simple) class names to the corresponding discovered plugin instance + private Map testPlugins = new HashMap<>(); + + // Set of dependent args for which we've seen values (requires predecessor) + private Set requiredPredecessors = new HashSet<>(); + + ///////////////////////////////////////////////////////// + // TestCommandLinePluginDescriptor implementation methods + + /** + * Return a display name to identify this plugin to the user + * @return A short user-friendly name for this plugin. + */ + @Override + public String getDisplayName() { return "testPlugin"; } + + /** + * @return the class object for the base class of all plugins managed by this descriptor + */ + @Override + public Class getPluginClass() {return pluginBaseClass;} + + /** + * A list of package names which will be searched for plugins managed by the descriptor. + * @return + */ + @Override + public List getPackageNames() {return Collections.singletonList(pluginPackageName);}; + + @Override + public Predicate> getClassFilter() { + return c -> { // don't use the Plugin base class + return !c.getName().equals(this.getPluginClass().getName()); + }; + } + + // Instantiate a new ReadFilter derived object and save it in the list + @Override + public Object getInstance(final Class pluggableClass) throws IllegalAccessException, InstantiationException { + TestPluginBase testPluginBase = null; + final String simpleName = pluggableClass.getSimpleName(); + + if (testPlugins.containsKey(simpleName)) { + // we found a plugin class with a name that collides with an existing class; + // plugin names must be unique even across packages + throw new IllegalArgumentException( + String.format("A plugin class name collision was detected (%s/%s). " + + "Simple names of plugin classes must be unique across packages.", + pluggableClass.getName(), + testPlugins.get(simpleName).getClass().getName()) + ); + } else { + testPluginBase = (TestPluginBase) pluggableClass.newInstance(); + testPlugins.put(simpleName, testPluginBase); + } + return testPluginBase; + } + + @Override + public boolean isDependentArgumentAllowed(final Class dependentClass) { + // make sure the predecessor for this dependent class was either specified + // on the command line or is a tool default, otherwise reject it + String predecessorName = dependentClass.getSimpleName(); + boolean isAllowed = userPluginNames.contains(predecessorName); + if (isAllowed) { + // keep track of the ones we allow so we can validate later that they + // weren't subsequently disabled + requiredPredecessors.add(predecessorName); + } + return isAllowed; + } + + /** + * Pass back the list of ReadFilter instances that were actually seen on the + * command line in the same order they were specified. This list does not + * include the tool defaults. + */ + @Override + public List getAllInstances() { + // Add the instances in the order they were specified on the command line + // + final ArrayList filters = new ArrayList<>(userPluginNames.size()); + userPluginNames.forEach(s -> filters.add(testPlugins.get(s))); + return filters; + } + + // Return the allowable values for readFilterNames/disableReadFilter + @Override + public Set getAllowedValuesForDescriptorArgument(final String longArgName) { + if (longArgName.equals(testPluginArgumentName)) { + return testPlugins.keySet(); + } + throw new IllegalArgumentException("Allowed values request for unrecognized string argument: " + longArgName); + } + + /** + * Validate the list of arguments and reduce the list of plugins to those + * actually seen on the command line. This is called by the command line parser + * after all arguments have been parsed. + */ + @Override + public void validateArguments() { + Set seenNames = new HashSet<>(); + seenNames.addAll(userPluginNames); + + Set validNames = new HashSet<>(); + validNames.add(org.broadinstitute.barclay.argparser.CommandLinePluginUnitTest.TestPluginWithRequiredArg.class.getSimpleName()); + validNames.add(org.broadinstitute.barclay.argparser.CommandLinePluginUnitTest.TestPluginWithOptionalArg.class.getSimpleName()); + validNames.add(org.broadinstitute.barclay.argparser.CommandLinePluginUnitTest.TestPlugin.class.getSimpleName()); + + if (seenNames.retainAll(validNames)) { + throw new CommandLineException.BadArgumentValue("Illegal command line plugin specified"); + } + userPluginNames.retainAll(seenNames); + } + + } + + @CommandLineProgramProperties( + summary = "Plugin Test", + oneLineSummary = "Plugin test", + programGroup = TestProgramGroup.class + ) + public class PlugInTestObject { + } + + @DataProvider(name="pluginTests") + public Object[][] pluginTests() { + return new Object[][]{ + {new String[0], 0}, + {new String[]{"--" + TestPluginDescriptor.testPluginArgumentName, TestPlugin.class.getSimpleName()}, 1} + }; + } + + @Test(dataProvider = "pluginTests") + public void testBasicPlugin(final String[] args, final int expectedInstanceCount){ + + PlugInTestObject plugInTest = new PlugInTestObject(); + final CommandLineArgumentParser clp = new CommandLineArgumentParser( + plugInTest, + Collections.singletonList(new TestPluginDescriptor())); + + Assert.assertTrue(clp.parseArguments(System.err, args)); + + TestPluginDescriptor pid = clp.getPluginDescriptor(TestPluginDescriptor.class); + Assert.assertNotNull(pid); + + List pluginBases = pid.getAllInstances(); + + Assert.assertEquals(pluginBases.size(), expectedInstanceCount); + } + + @Test + public void testPluginUsage() { + PlugInTestObject plugInTest = new PlugInTestObject(); + final CommandLineArgumentParser clp = new CommandLineArgumentParser( + plugInTest, + Collections.singletonList(new TestPluginDescriptor())); + final String out = CommandLineArgumentParserTest.captureStderr(() -> clp.usage(System.err, true)); // with common args + + TestPluginDescriptor pid = clp.getPluginDescriptor(TestPluginDescriptor.class); + Assert.assertNotNull(pid); + + // Make sure TestPlugin.argumentName is listed as conditional + final int condIndex = out.indexOf("Conditional Arguments"); + Assert.assertTrue(condIndex > 0); + final int argIndex = out.indexOf(TestPlugin.argumentName); + Assert.assertTrue(argIndex > condIndex); + } + + + @DataProvider(name="pluginsWithRequiredArguments") + public Object[][] pluginsWithRequiredArguments(){ + return new Object[][]{ + { TestPluginWithRequiredArg.class.getSimpleName(), TestPluginWithRequiredArg.requiredArgName, "fakeArgValue" } + }; + } + + // fail if a plugin with required arguments is specified without the corresponding required arguments + @Test(dataProvider = "pluginsWithRequiredArguments", expectedExceptions = CommandLineException.MissingArgument.class) + public void testRequiredDependentArguments( + final String plugin, + final String argName, //unused + final String argValue) //unused + { + CommandLineParser clp = new CommandLineArgumentParser(new Object(), + Collections.singletonList(new TestPluginDescriptor())); + String[] args = { + "--" + TestPluginDescriptor.testPluginArgumentName, plugin // no args, just enable plugin + }; + + clp.parseArguments(System.out, args); + } + + @DataProvider(name="pluginsWithArguments") + public Object[][] pluginsWithArguments(){ + return new Object[][]{ + { TestPluginWithRequiredArg.class.getSimpleName(), TestPluginWithRequiredArg.requiredArgName, "fakeArgValue" }, + { TestPluginWithOptionalArg.class.getSimpleName(), TestPluginWithOptionalArg.optionalArgName, "fakeArgValue" } + }; + } + + // fail if a plugin's arguments are passed but the plugin itself is not specified + @Test(dataProvider = "pluginsWithArguments", expectedExceptions = CommandLineException.class) + public void testDanglingFilterArguments( + final String filter, // unused + final String argName, + final String argValue) + { + CommandLineParser clp = new CommandLineArgumentParser(new Object(), + Collections.singletonList(new TestPluginDescriptor())); + + String[] args = { argName, argValue }; // plugin args are specified but no plugin actually specified + + clp.parseArguments(System.out, args); + } + + @Test + public void testNoPluginsSpecified() { + CommandLineParser clp = new CommandLineArgumentParser(new Object(), + Collections.singletonList(new TestPluginDescriptor())); + clp.parseArguments(System.out, new String[]{}); + + // get the command line read plugins + final TestPluginDescriptor pluginDescriptor = clp.getPluginDescriptor(TestPluginDescriptor.class); + final List plugins = pluginDescriptor.getAllInstances(); + Assert.assertEquals(plugins.size(), 0); + } + + @Test + public void testEnableMultiplePlugins() { + CommandLineParser clp = new CommandLineArgumentParser(new Object(), + Collections.singletonList(new TestPluginDescriptor())); + String[] args = { + "--" + TestPluginDescriptor.testPluginArgumentName, TestPluginWithRequiredArg.class.getSimpleName(), + "--" + TestPluginWithRequiredArg.requiredArgName, "fake", + "--" + TestPluginDescriptor.testPluginArgumentName, TestPluginWithOptionalArg.class.getSimpleName(), + "--" + TestPluginWithOptionalArg.optionalArgName, "alsofake" + }; + clp.parseArguments(System.out, args); + + // get the command line plugins + final TestPluginDescriptor pluginDescriptor = clp.getPluginDescriptor(TestPluginDescriptor.class); + final List plugins = pluginDescriptor.getAllInstances(); + Assert.assertEquals(plugins.size(), 2); + Assert.assertEquals(plugins.get(0).getClass().getSimpleName(), TestPluginWithRequiredArg.class.getSimpleName()); + Assert.assertEquals(plugins.get(1).getClass().getSimpleName(), TestPluginWithOptionalArg.class.getSimpleName()); + } + + @Test(expectedExceptions = CommandLineException.class) + public void testEnableNonExistentPlugin() { + CommandLineParser clp = new CommandLineArgumentParser(new Object(), + Collections.singletonList(new TestPluginDescriptor())); + clp.parseArguments(System.out, new String[] {"--" + TestPluginDescriptor.testPluginArgumentName, "nonExistentPlugin"}); + } + + //////////////////////////////////////////// + //Begin plugin argument name collision tests + + public static class TestPluginArgCollisionBase { + } + + public static class TestPluginArgCollision1 extends TestPluginArgCollisionBase { + public final static String argumentName = "argumentForTestCollisionPlugin"; + @Argument(fullName = argumentName, optional=true) + Integer argumentForTestPlugin; + } + + // This class isn't explicitly referenced anywhere, but it needs to be here so the command line parser + // will find it on behalf of the TestPluginArgCollisionDescriptor when running the collision test. This + // will result in an argument namespace collision, which is what we're testing. + public static class TestPluginArgCollision2 extends TestPluginArgCollisionBase { + + //deliberately create an arg name collision with TestPluginArgCollision1 + @Argument(fullName = TestPluginArgCollision1.argumentName, optional=true) + Integer argumentForTestPlugin; + } + + // This descriptor should only be used for the namespace collision tests since it has a...namespace collision + public static class TestPluginArgCollisionDescriptor extends CommandLinePluginDescriptor { + + final String collisionPluginArgName = "collisionPluginName"; + + @Argument(fullName=collisionPluginArgName, optional = true) + Set pluginNames = new HashSet<>(); + + // Map of plugin names to the corresponding instance + public Map pluginInstances = new HashMap<>(); + + public TestPluginArgCollisionDescriptor() {} + + @Override + public Class getPluginClass() { + return TestPluginArgCollisionBase.class; + } + + @Override + public List getPackageNames() { + return Collections.singletonList("org.broadinstitute.barclay.argparser"); + } + + @Override + public Predicate> getClassFilter() { + return c -> { + // don't use the TestPlugin base class + return !c.getName().equals(this.getPluginClass().getName()); + }; + } + + @Override + public Object getInstance(Class pluggableClass) throws IllegalAccessException, InstantiationException { + final TestPluginArgCollisionBase plugin = (TestPluginArgCollisionBase) pluggableClass.newInstance(); + pluginInstances.put(pluggableClass.getSimpleName(), plugin); + return plugin; + } + + @Override + public Set getAllowedValuesForDescriptorArgument(String longArgName) { + if (longArgName.equals(collisionPluginArgName) ){ + return pluginInstances.keySet(); + } + throw new IllegalArgumentException("Allowed values request for unrecognized string argument: " + longArgName); + + } + @Override + public boolean isDependentArgumentAllowed(Class targetPluginClass) { + return true; + } + + @Override + public void validateArguments() { + // remove the un-specified plugin instances + Map requestedPlugins = new HashMap<>(); + pluginNames.forEach(s -> { + TestPluginArgCollisionBase trf = pluginInstances.get(s); + if (null == trf) { + throw new CommandLineException("Unrecognized test plugin name: " + s); + } + else { + requestedPlugins.put(s, trf); + } + }); + pluginInstances = requestedPlugins; + + // now validate that each plugin specified is valid (has a corresponding instance) + Assert.assertEquals(pluginNames.size(), pluginInstances.size()); + } + + @Override + public List getAllInstances() { + List pluginList = new ArrayList<>(); + pluginList.addAll(pluginInstances.values()); + return pluginList; + } + } + + @Test(expectedExceptions=CommandLineException.CommandLineParserInternalException.class) + public void testPluginArgumentNameCollision(){ + PlugInTestObject PlugInTestObject = new PlugInTestObject(); + // just the act of passing this descriptor to the parser should cause the collision + new CommandLineArgumentParser( + PlugInTestObject, + Collections.singletonList(new TestPluginArgCollisionDescriptor())); + } + +} diff --git a/src/test/java/org/broadinstitute/barclay/argparser/LegacyCommandLineArgumentParserTest.java b/src/test/java/org/broadinstitute/barclay/argparser/LegacyCommandLineArgumentParserTest.java new file mode 100644 index 00000000..f565dd12 --- /dev/null +++ b/src/test/java/org/broadinstitute/barclay/argparser/LegacyCommandLineArgumentParserTest.java @@ -0,0 +1,812 @@ +/* + * The MIT License + * + * Copyright (c) 2009 The Broad Institute + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package org.broadinstitute.barclay.argparser; + +import org.testng.Assert; +import org.testng.annotations.DataProvider; +import org.testng.annotations.Test; + +import java.io.*; +import java.util.*; + +public class LegacyCommandLineArgumentParserTest { + + enum FrobnicationFlavor { + FOO, BAR, BAZ + } + + @CommandLineProgramProperties( + summary = "Usage: frobnicate [options] input-file output-file\n\nRead input-file, frobnicate it, and write frobnicated results to output-file\n", + oneLineSummary = "Read input-file, frobnicate it, and write frobnicated results to output-file", + programGroup = TestProgramGroup.class + ) + class FrobnicateOptions { + + @PositionalArguments(minElements = 2, maxElements = 2) + public List positionalArguments = new ArrayList(); + + @Argument(shortName = "T", doc = "Frobnication threshold setting.") + public Integer FROBNICATION_THRESHOLD = 20; + + @Argument + public FrobnicationFlavor FROBNICATION_FLAVOR; + + @Argument(doc = "Allowed shmiggle types.", minElements = 1, maxElements = 3) + public List SHMIGGLE_TYPE = new ArrayList(); + + @Argument + public Boolean TRUTHINESS; + } + + @CommandLineProgramProperties( + summary = "Usage: frobnicate [options] input-file output-file\n\nRead input-file, frobnicate it, and write frobnicated results to output-file\n", + oneLineSummary = "Read input-file, frobnicate it, and write frobnicated results to output-file", + programGroup = TestProgramGroup.class + ) + class FrobnicateOptionsWithNullList { + + @PositionalArguments(minElements = 2, maxElements = 2) + public List positionalArguments = new ArrayList(); + + @Argument(shortName = "T", doc = "Frobnication threshold setting.") + public Integer FROBNICATION_THRESHOLD = 20; + + @Argument + public FrobnicationFlavor FROBNICATION_FLAVOR; + + @Argument(doc = "Allowed shmiggle types.", minElements = 0, maxElements = 3) + public List SHMIGGLE_TYPE = new ArrayList(); + + @Argument + public Boolean TRUTHINESS; + } + + @CommandLineProgramProperties( + summary = "Usage: framistat [options]\n\nCompute the plebnick of the freebozzle.\n", + oneLineSummary = "ompute the plebnick of the freebozzle", + programGroup = TestProgramGroup.class + ) + class OptionsWithoutPositional { + public static final int DEFAULT_FROBNICATION_THRESHOLD = 20; + @Argument(shortName = "T", doc = "Frobnication threshold setting.") + public Integer FROBNICATION_THRESHOLD = DEFAULT_FROBNICATION_THRESHOLD; + + @Argument + public FrobnicationFlavor FROBNICATION_FLAVOR; + + @Argument(doc = "Allowed shmiggle types.", minElements = 1, maxElements = 3) + public List SHMIGGLE_TYPE = new ArrayList(); + + @Argument + public Boolean TRUTHINESS; + } + + class OptionsWithCaseClash { + @Argument + public String FROB; + @Argument + public String frob; + } + + class OptionsWithSameShortName { + @Argument(shortName = "SAME_SHORT_NAME", overridable = true, optional = true) + public String SAME_SHORT_NAME; + @Argument(shortName = "SOMETHING_ELSE", overridable = true, optional = true) + public String DIFF_SHORT_NAME; + } + + class MutexOptions { + @Argument(mutex = {"M", "N", "Y", "Z"}) + public String A; + @Argument(mutex = {"M", "N", "Y", "Z"}) + public String B; + @Argument(mutex = {"A", "B", "Y", "Z"}) + public String M; + @Argument(mutex = {"A", "B", "Y", "Z"}) + public String N; + @Argument(mutex = {"A", "B", "M", "N"}) + public String Y; + @Argument(mutex = {"A", "B", "M", "N"}) + public String Z; + + } + + + @Test + public void testUsage() { + final FrobnicateOptions fo = new FrobnicateOptions(); + final LegacyCommandLineArgumentParser clp = new LegacyCommandLineArgumentParser(fo); + clp.usage(System.out, false); + } + + @Test + public void testUsageWithDefault() { + final FrobnicateOptions fo = new FrobnicateOptions(); + final LegacyCommandLineArgumentParser clp = new LegacyCommandLineArgumentParser(fo); + clp.usage(System.out, true); + } + + @Test + public void testUsageWithoutPositional() { + final OptionsWithoutPositional fo = new OptionsWithoutPositional(); + final LegacyCommandLineArgumentParser clp = new LegacyCommandLineArgumentParser(fo); + clp.usage(System.out, false); + } + + @Test + public void testUsageWithoutPositionalWithDefault() { + final OptionsWithoutPositional fo = new OptionsWithoutPositional(); + final LegacyCommandLineArgumentParser clp = new LegacyCommandLineArgumentParser(fo); + clp.usage(System.out, true); + } + + /** + * If the short name is set to be the same as the long name we still want the argument to appear in the commandLine. + */ + @Test + public void testForIdenticalShortName() { + final String[] args = { + "SAME_SHORT_NAME=FOO", + "SOMETHING_ELSE=BAR" + }; + final OptionsWithSameShortName fo = new OptionsWithSameShortName(); + final LegacyCommandLineArgumentParser clp = new LegacyCommandLineArgumentParser(fo); + clp.parseArguments(System.err, args); + final String commandLine = clp.getCommandLine(); + Assert.assertTrue(commandLine.contains("DIFF_SHORT_NAME")); + Assert.assertTrue(commandLine.contains("SAME_SHORT_NAME")); + } + + + @Test + public void testPositive() { + final String[] args = { + "T=17", + "FROBNICATION_FLAVOR=BAR", + "TRUTHINESS=False", + "SHMIGGLE_TYPE=shmiggle1", + "SHMIGGLE_TYPE=shmiggle2", + "positional1", + "positional2", + }; + final FrobnicateOptions fo = new FrobnicateOptions(); + final LegacyCommandLineArgumentParser clp = new LegacyCommandLineArgumentParser(fo); + Assert.assertTrue(clp.parseArguments(System.err, args)); + Assert.assertEquals(fo.positionalArguments.size(), 2); + final File[] expectedPositionalArguments = {new File("positional1"), new File("positional2")}; + Assert.assertEquals(fo.positionalArguments.toArray(), expectedPositionalArguments); + Assert.assertEquals(fo.FROBNICATION_THRESHOLD.intValue(), 17); + Assert.assertEquals(fo.FROBNICATION_FLAVOR, FrobnicationFlavor.BAR); + Assert.assertEquals(fo.SHMIGGLE_TYPE.size(), 2); + final String[] expectedShmiggleTypes = {"shmiggle1", "shmiggle2"}; + Assert.assertEquals(fo.SHMIGGLE_TYPE.toArray(), expectedShmiggleTypes); + Assert.assertFalse(fo.TRUTHINESS); + } + + /** + * Allow a whitespace btw equal sign and option value. + */ + @Test + public void testPositiveWithSpaces() { + final String[] args = { + "T=", "17", + "FROBNICATION_FLAVOR=", "BAR", + "TRUTHINESS=", "False", + "SHMIGGLE_TYPE=", "shmiggle1", + "SHMIGGLE_TYPE=", "shmiggle2", + "positional1", + "positional2", + }; + final FrobnicateOptions fo = new FrobnicateOptions(); + final LegacyCommandLineArgumentParser clp = new LegacyCommandLineArgumentParser(fo); + Assert.assertTrue(clp.parseArguments(System.err, args)); + Assert.assertEquals(fo.positionalArguments.size(), 2); + final File[] expectedPositionalArguments = {new File("positional1"), new File("positional2")}; + Assert.assertEquals(fo.positionalArguments.toArray(), expectedPositionalArguments); + Assert.assertEquals(fo.FROBNICATION_THRESHOLD.intValue(), 17); + Assert.assertEquals(fo.FROBNICATION_FLAVOR, FrobnicationFlavor.BAR); + Assert.assertEquals(fo.SHMIGGLE_TYPE.size(), 2); + final String[] expectedShmiggleTypes = {"shmiggle1", "shmiggle2"}; + Assert.assertEquals(fo.SHMIGGLE_TYPE.toArray(), expectedShmiggleTypes); + Assert.assertFalse(fo.TRUTHINESS); + } + + @Test + public void testPositiveWithoutPositional() { + final String[] args = { + "T=17", + "FROBNICATION_FLAVOR=BAR", + "TRUTHINESS=False", + "SHMIGGLE_TYPE=shmiggle1", + "SHMIGGLE_TYPE=shmiggle2", + }; + final OptionsWithoutPositional fo = new OptionsWithoutPositional(); + final LegacyCommandLineArgumentParser clp = new LegacyCommandLineArgumentParser(fo); + Assert.assertTrue(clp.parseArguments(System.err, args)); + Assert.assertEquals(fo.FROBNICATION_THRESHOLD.intValue(), 17); + Assert.assertEquals(fo.FROBNICATION_FLAVOR, FrobnicationFlavor.BAR); + Assert.assertEquals(fo.SHMIGGLE_TYPE.size(), 2); + final String[] expectedShmiggleTypes = {"shmiggle1", "shmiggle2"}; + Assert.assertEquals(fo.SHMIGGLE_TYPE.toArray(), expectedShmiggleTypes); + Assert.assertFalse(fo.TRUTHINESS); + } + + /** + * If last character of command line is the equal sign in an option=value pair, + * make sure no crash, and that the value is empty string. + */ + @Test + public void testPositiveTerminalEqualSign() { + final String[] args = { + "T=17", + "FROBNICATION_FLAVOR=BAR", + "TRUTHINESS=False", + "SHMIGGLE_TYPE=shmiggle1", + "SHMIGGLE_TYPE=", + }; + final OptionsWithoutPositional fo = new OptionsWithoutPositional(); + final LegacyCommandLineArgumentParser clp = new LegacyCommandLineArgumentParser(fo); + Assert.assertTrue(clp.parseArguments(System.err, args)); + Assert.assertEquals(fo.FROBNICATION_THRESHOLD.intValue(), 17); + Assert.assertEquals(fo.FROBNICATION_FLAVOR, FrobnicationFlavor.BAR); + Assert.assertEquals(fo.SHMIGGLE_TYPE.size(), 2); + final String[] expectedShmiggleTypes = {"shmiggle1", ""}; + Assert.assertEquals(fo.SHMIGGLE_TYPE.toArray(), expectedShmiggleTypes); + Assert.assertFalse(fo.TRUTHINESS); + } + + @Test + public void testDefault() { + final String[] args = { + "FROBNICATION_FLAVOR=BAR", + "TRUTHINESS=False", + "SHMIGGLE_TYPE=shmiggle1", + "SHMIGGLE_TYPE=shmiggle2", + "positional1", + "positional2", + }; + final FrobnicateOptions fo = new FrobnicateOptions(); + final LegacyCommandLineArgumentParser clp = new LegacyCommandLineArgumentParser(fo); + Assert.assertTrue(clp.parseArguments(System.err, args)); + Assert.assertEquals(fo.FROBNICATION_THRESHOLD.intValue(), 20); + } + + @Test + public void testMissingRequiredArgument() { + final String[] args = { + "TRUTHINESS=False", + "SHMIGGLE_TYPE=shmiggle1", + "SHMIGGLE_TYPE=shmiggle2", + "positional1", + "positional2", + }; + final FrobnicateOptions fo = new FrobnicateOptions(); + final LegacyCommandLineArgumentParser clp = new LegacyCommandLineArgumentParser(fo); + Assert.assertFalse(clp.parseArguments(System.err, args)); + } + + @Test + public void testBadValue() { + final String[] args = { + "FROBNICATION_THRESHOLD=ABC", + "FROBNICATION_FLAVOR=BAR", + "TRUTHINESS=False", + "SHMIGGLE_TYPE=shmiggle1", + "SHMIGGLE_TYPE=shmiggle2", + "positional1", + "positional2", + }; + final FrobnicateOptions fo = new FrobnicateOptions(); + final LegacyCommandLineArgumentParser clp = new LegacyCommandLineArgumentParser(fo); + Assert.assertFalse(clp.parseArguments(System.err, args)); + } + + @Test + public void testBadEnumValue() { + final String[] args = { + "FROBNICATION_FLAVOR=HiMom", + "TRUTHINESS=False", + "SHMIGGLE_TYPE=shmiggle1", + "SHMIGGLE_TYPE=shmiggle2", + "positional1", + "positional2", + }; + final FrobnicateOptions fo = new FrobnicateOptions(); + final LegacyCommandLineArgumentParser clp = new LegacyCommandLineArgumentParser(fo); + Assert.assertFalse(clp.parseArguments(System.err, args)); + } + + @Test + public void testNotEnoughOfListOption() { + final String[] args = { + "FROBNICATION_FLAVOR=BAR", + "TRUTHINESS=False", + "positional1", + "positional2", + }; + final FrobnicateOptions fo = new FrobnicateOptions(); + final LegacyCommandLineArgumentParser clp = new LegacyCommandLineArgumentParser(fo); + Assert.assertFalse(clp.parseArguments(System.err, args)); + } + + @Test + public void testTooManyListOption() { + final String[] args = { + "FROBNICATION_FLAVOR=BAR", + "TRUTHINESS=False", + "SHMIGGLE_TYPE=shmiggle1", + "SHMIGGLE_TYPE=shmiggle2", + "SHMIGGLE_TYPE=shmiggle3", + "SHMIGGLE_TYPE=shmiggle4", + "positional1", + "positional2", + }; + final FrobnicateOptions fo = new FrobnicateOptions(); + final LegacyCommandLineArgumentParser clp = new LegacyCommandLineArgumentParser(fo); + Assert.assertFalse(clp.parseArguments(System.err, args)); + } + + @Test + public void testTooManyPositional() { + final String[] args = { + "FROBNICATION_FLAVOR=BAR", + "TRUTHINESS=False", + "SHMIGGLE_TYPE=shmiggle1", + "SHMIGGLE_TYPE=shmiggle2", + "positional1", + "positional2", + "positional3", + }; + final FrobnicateOptions fo = new FrobnicateOptions(); + final LegacyCommandLineArgumentParser clp = new LegacyCommandLineArgumentParser(fo); + Assert.assertFalse(clp.parseArguments(System.err, args)); + } + + @Test + public void testNotEnoughPositional() { + final String[] args = { + "FROBNICATION_FLAVOR=BAR", + "TRUTHINESS=False", + "SHMIGGLE_TYPE=shmiggle1", + "SHMIGGLE_TYPE=shmiggle2", + }; + final FrobnicateOptions fo = new FrobnicateOptions(); + final LegacyCommandLineArgumentParser clp = new LegacyCommandLineArgumentParser(fo); + Assert.assertFalse(clp.parseArguments(System.err, args)); + } + + @Test + public void testUnexpectedPositional() { + final String[] args = { + "T=17", + "FROBNICATION_FLAVOR=BAR", + "TRUTHINESS=False", + "SHMIGGLE_TYPE=shmiggle1", + "SHMIGGLE_TYPE=shmiggle2", + "positional" + }; + final OptionsWithoutPositional fo = new OptionsWithoutPositional(); + final LegacyCommandLineArgumentParser clp = new LegacyCommandLineArgumentParser(fo); + Assert.assertFalse(clp.parseArguments(System.err, args)); + } + + @Test(expectedExceptions = CommandLineException.CommandLineParserInternalException.class) + public void testOptionDefinitionCaseClash() { + final OptionsWithCaseClash options = new OptionsWithCaseClash(); + new LegacyCommandLineArgumentParser(options); + Assert.fail("Should not be reached."); + } + + @Test + public void testOptionUseCaseClash() { + final String[] args = { + "FROBNICATION_FLAVOR=BAR", + "FrOBNICATION_fLAVOR=BAR", + }; + final FrobnicateOptions fo = new FrobnicateOptions(); + final LegacyCommandLineArgumentParser clp = new LegacyCommandLineArgumentParser(fo); + Assert.assertFalse(clp.parseArguments(System.err, args)); + } + + @Test + public void testNullValue() { + final String[] args = { + "FROBNICATION_THRESHOLD=null", + "FROBNICATION_FLAVOR=BAR", + "TRUTHINESS=False", + "SHMIGGLE_TYPE=null", + "positional1", + "positional2", + }; + + final FrobnicateOptionsWithNullList fownl = new FrobnicateOptionsWithNullList(); + fownl.SHMIGGLE_TYPE.add("shmiggle1"); //providing null value should clear this list + + final LegacyCommandLineArgumentParser clp = new LegacyCommandLineArgumentParser(fownl); + Assert.assertTrue(clp.parseArguments(System.err, args)); + Assert.assertEquals(fownl.positionalArguments.size(), 2); + final File[] expectedPositionalArguments = {new File("positional1"), new File("positional2")}; + Assert.assertEquals(fownl.positionalArguments.toArray(), expectedPositionalArguments); + Assert.assertEquals(fownl.FROBNICATION_THRESHOLD, null); //test null value + Assert.assertEquals(fownl.SHMIGGLE_TYPE.size(), 0); //test null value for list + Assert.assertFalse(fownl.TRUTHINESS); + + //verify that required arg can't be set to null + args[2] = "TRUTHINESS=null"; + final LegacyCommandLineArgumentParser clp2 = new LegacyCommandLineArgumentParser(fownl); + Assert.assertFalse(clp2.parseArguments(System.err, args)); + + //verify that positional arg can't be set to null + args[2] = "TRUTHINESS=False"; + args[4] = "null"; + final LegacyCommandLineArgumentParser clp3 = new LegacyCommandLineArgumentParser(fownl); + Assert.assertFalse(clp3.parseArguments(System.err, args)); + + } + + + @Test + public void testOptionsFile() throws Exception { + final File optionsFile = File.createTempFile("clp.", ".options"); + optionsFile.deleteOnExit(); + final PrintWriter writer = new PrintWriter(optionsFile); + writer.println("T=18"); + writer.println("TRUTHINESS=True"); + writer.println("SHMIGGLE_TYPE=shmiggle0"); + writer.println("STRANGE_OPTION=shmiggle0"); + writer.close(); + final String[] args = { + "OPTIONS_FILE=" + optionsFile.getPath(), + // Multiple options files are allowed + "OPTIONS_FILE=" + optionsFile.getPath(), + "T=17", + "FROBNICATION_FLAVOR=BAR", + "TRUTHINESS=False", + "SHMIGGLE_TYPE=shmiggle1", + "positional1", + "positional2", + }; + final FrobnicateOptions fo = new FrobnicateOptions(); + final LegacyCommandLineArgumentParser clp = new LegacyCommandLineArgumentParser(fo); + Assert.assertTrue(clp.parseArguments(System.err, args)); + Assert.assertEquals(fo.positionalArguments.size(), 2); + final File[] expectedPositionalArguments = {new File("positional1"), new File("positional2")}; + Assert.assertEquals(fo.positionalArguments.toArray(), expectedPositionalArguments); + Assert.assertEquals(fo.FROBNICATION_THRESHOLD.intValue(), 17); + Assert.assertEquals(fo.FROBNICATION_FLAVOR, FrobnicationFlavor.BAR); + Assert.assertEquals(fo.SHMIGGLE_TYPE.size(), 3); + final String[] expectedShmiggleTypes = {"shmiggle0", "shmiggle0", "shmiggle1"}; + Assert.assertEquals(fo.SHMIGGLE_TYPE.toArray(), expectedShmiggleTypes); + Assert.assertFalse(fo.TRUTHINESS); + } + + + /** + * In an options file, should not be allowed to override an option set on the command line + * + * @throws Exception + */ + @Test + public void testOptionsFileWithDisallowedOverride() throws Exception { + final File optionsFile = File.createTempFile("clp.", ".options"); + optionsFile.deleteOnExit(); + final PrintWriter writer = new PrintWriter(optionsFile); + writer.println("T=18"); + writer.close(); + final String[] args = { + "T=17", + "OPTIONS_FILE=" + optionsFile.getPath() + }; + final FrobnicateOptions fo = new FrobnicateOptions(); + final LegacyCommandLineArgumentParser clp = new LegacyCommandLineArgumentParser(fo); + Assert.assertFalse(clp.parseArguments(System.err, args)); + } + + @DataProvider(name = "mutexScenarios") + public Object[][] mutexScenarios() { + return new Object[][]{ + {"pass", new String[]{"A=1", "B=2"}, true}, + {"no args", new String[0], false}, + {"1 of group required", new String[]{"A=1"}, false}, + {"mutex", new String[]{"A=1", "Y=3"}, false}, + {"mega mutex", new String[]{"A=1", "B=2", "Y=3", "Z=1", "M=2", "N=3"}, false} + }; + } + + @Test(dataProvider = "mutexScenarios") + public void testMutex(final String testName, final String[] args, final boolean expected) { + final MutexOptions o = new MutexOptions(); + final LegacyCommandLineArgumentParser clp = new LegacyCommandLineArgumentParser(o); + Assert.assertEquals(clp.parseArguments(System.err, args), expected); + } + + class UninitializedCollectionOptions { + @Argument + public List LIST; + @Argument + public ArrayList ARRAY_LIST; + @Argument + public HashSet HASH_SET; + @PositionalArguments + public Collection COLLECTION; + + } + + @Test + public void testUninitializedCollections() { + final UninitializedCollectionOptions o = new UninitializedCollectionOptions(); + final LegacyCommandLineArgumentParser clp = new LegacyCommandLineArgumentParser(o); + final String[] args = {"LIST=L1", "LIST=L2", "ARRAY_LIST=S1", "HASH_SET=HS1", "P1", "P2"}; + Assert.assertTrue(clp.parseArguments(System.err, args)); + Assert.assertEquals(o.LIST.size(), 2); + Assert.assertEquals(o.ARRAY_LIST.size(), 1); + Assert.assertEquals(o.HASH_SET.size(), 1); + Assert.assertEquals(o.COLLECTION.size(), 2); + } + + class UninitializedCollectionThatCannotBeAutoInitializedOptions { + @Argument + public Set SET; + } + + @Test(expectedExceptions = CommandLineException.CommandLineParserInternalException.class) + public void testCollectionThatCannotBeAutoInitialized() { + final UninitializedCollectionThatCannotBeAutoInitializedOptions o = new UninitializedCollectionThatCannotBeAutoInitializedOptions(); + new LegacyCommandLineArgumentParser(o); + Assert.fail("Exception should have been thrown"); + } + + class CollectionWithDefaultValuesOptions { + @Argument + public List LIST = makeList("foo", "bar"); + } + + @Test + public void testClearDefaultValuesFromListOption() { + final CollectionWithDefaultValuesOptions o = new CollectionWithDefaultValuesOptions(); + final LegacyCommandLineArgumentParser clp = new LegacyCommandLineArgumentParser(o); + final String[] args = {"LIST=null"}; + Assert.assertTrue(clp.parseArguments(System.err, args)); + Assert.assertEquals(o.LIST.size(), 0); + } + + @Test + public void testClearDefaultValuesFromListOptionAndAddNew() { + final CollectionWithDefaultValuesOptions o = new CollectionWithDefaultValuesOptions(); + final LegacyCommandLineArgumentParser clp = new LegacyCommandLineArgumentParser(o); + final String[] args = {"LIST=null", "LIST=baz", "LIST=frob"}; + Assert.assertTrue(clp.parseArguments(System.err, args)); + Assert.assertEquals(o.LIST, makeList("baz", "frob")); + } + + @Test + public void testAddToDefaultValuesListOption() { + final CollectionWithDefaultValuesOptions o = new CollectionWithDefaultValuesOptions(); + final LegacyCommandLineArgumentParser clp = new LegacyCommandLineArgumentParser(o); + final String[] args = {"LIST=baz", "LIST=frob"}; + Assert.assertTrue(clp.parseArguments(System.err, args)); + Assert.assertEquals(o.LIST, makeList("foo", "bar", "baz", "frob")); + } + + private List makeList(final String... list) { + final List result = new ArrayList<>(); + Collections.addAll(result, list); + return result; + } + + class StaticParent { + + @Argument + public String STRING1 = "String1ParentDefault"; + + @Argument + public String STRING2 = "String2ParentDefault"; + + @Argument(overridable = true) + public String STRING3 = "String3ParentDefault"; + + public void doSomething() { + System.out.println(STRING3); + } + + } + + class OverridePropagation extends StaticParent { + @Argument + public String STRING3 = "String3Overriden"; + } + + @Test + public void testOveriddenOptions() { + final OverridePropagation overridden = new OverridePropagation(); + final LegacyCommandLineArgumentParser overrideClp = new LegacyCommandLineArgumentParser(overridden); + + overrideClp.parseArguments(System.err, new String[0]); + + final OverridePropagation props = (OverridePropagation) overrideClp.getCallerOptions(); + Assert.assertTrue(props.STRING3.equals("String3Overriden")); + Assert.assertTrue(((StaticParent) props).STRING3.equals("String3Overriden")); + + overrideClp.parseArguments(System.err, new String[]{"STRING3=String3Supplied"}); + + final OverridePropagation propsSet = (OverridePropagation) overrideClp.getCallerOptions(); + Assert.assertTrue(propsSet.STRING3.equals("String3Supplied")); + Assert.assertTrue(((StaticParent) propsSet).STRING3.equals("String3Supplied")); + } + + + @DataProvider(name = "testHtmlEscapeData") + public Object[][] testHtmlEscapeData() { + final List retval = new ArrayList<>(); + + retval.add(new Object[]{"<", "<"}); + retval.add(new Object[]{"x"}); + retval.add(new Object[]{" x y< x "," x<y y< x "}); + + return retval.toArray(new Object[0][]); + } + + @Test(dataProvider = "testHtmlEscapeData") + public void testHtmlUnescape(final String expected, final String html) { + Assert.assertEquals(LegacyCommandLineArgumentParser.htmlUnescape(html), expected, "problems"); + } + + @DataProvider(name = "testHTMLConverter") + public Object[][] testHTMLConverterData() { + final List retval = new ArrayList<>(); + + retval.add(new Object[]{"hello", "hello"}); + retval.add(new Object[]{"", ""}); + retval.add(new Object[]{"hibye", "hi\tbye"}); + retval.add(new Object[]{"hibye", "hibye"}); + retval.add(new Object[]{"hi

  • bye", "hi - bye"}); + retval.add(new Object[]{"hibye", "hibye"}); + retval.add(new Object[]{"
    ", "\n\n"});
    +        retval.add(new Object[]{" string", " string (http://go.here.org)"});
    +        retval.add(new Object[]{" string", " string (http://go.here.org)"});
    +        retval.add(new Object[]{"< a href=\"http://go.here.org\"> string", " string (http://go.here.org)"});
    +
    +
    +        //for some reason, the next test seems to break intelliJ, but it works on the commandline
    +        retval.add(new Object[]{"hi
  • bye", "hi\nbye"}); + + retval.add(new Object[]{"Using read outputs from high throughput sequencing (HTS) technologies, this tool provides " + + "metrics regarding the quality of read alignments to a reference sequence, as well as the proportion of the reads " + + "that passed machine signal-to-noise threshold quality filters (Illumina)." + + "

    Usage example:

    " + + "
    " +
    +                "    java -jar picard.jar CollectAlignmentSummaryMetrics \\
    " + + " R=reference_sequence.fasta \\
    " + + " I=input.bam \\
    " + + " O=output.txt" + + "
    " + + "Please see
    " + + "the AlignmentSummaryMetrics documentation for detailed explanations of each metric.

    " + + "Additional information about Illumina's quality filters can be found in the following documents on the Illumina website:" + + "" + + "
    ", + + "Using read outputs from high throughput sequencing (HTS) technologies, this tool provides " + + "metrics regarding the quality of read alignments to a reference sequence, as well as the proportion of the reads " + + "that passed machine signal-to-noise threshold quality filters (Illumina)." + + "\nUsage example:\n" + + "\n" + + " java -jar picard.jar CollectAlignmentSummaryMetrics \\\n" + + " R=reference_sequence.fasta \\\n" + + " I=input.bam \\\n" + + " O=output.txt" + + "\n" + + "Please see the AlignmentSummaryMetrics documentation (http://broadinstitute.github.io/picard/picard-metric-definitions.html#AlignmentSummaryMetrics) for detailed explanations of each metric. \n \n" + + "Additional information about Illumina's quality filters can be found in the following documents on the Illumina website:" + + "\n" + + " - hiseq-x-percent-pf-technical-note (http://support.illumina.com/content/dam/illumina-marketing/documents/products/technotes/hiseq-x-percent-pf-technical-note-770-2014-043.pdf)\n" + + " - hiseq-x-system-guide (http://support.illumina.com/content/dam/illumina-support/documents/documentation/system_documentation/hiseqx/hiseq-x-system-guide-15050091-d.pdf)\n\n" + + "\n"}); + + return retval.toArray(new Object[0][]); + } + + @Test(dataProvider = "testHTMLConverter") + public void testHTMLConverter(String input, String expected) { + final String converted = LegacyCommandLineArgumentParser.convertFromHtml(input); + Assert.assertEquals(converted, expected, "common part:\"" + expected.substring(0, lengthOfCommonSubstring(converted, expected)) + "\"\n\n"); + } + + @CommandLineProgramProperties( + summary = TestParserFail.USAGE_SUMMARY + TestParserFail.USAGE_DETAILS, + oneLineSummary = TestParserFail.USAGE_SUMMARY, + programGroup = TestProgramGroup.class + ) + protected class TestParserFail extends Object { + + static public final String USAGE_DETAILS = "blah &blah; blah "; + static public final String USAGE_SUMMARY = "This tool offers....."; + } + + @CommandLineProgramProperties( + summary = TestParserSucceed.USAGE_SUMMARY + TestParserSucceed.USAGE_DETAILS, + oneLineSummary = TestParserSucceed.USAGE_SUMMARY, + programGroup = TestProgramGroup.class + ) + protected class TestParserSucceed extends Object { + + static public final String USAGE_DETAILS = "This is the first row

    And this is the second"; + static public final String USAGE_SUMMARY = " X < Y "; + } + + @Test(expectedExceptions = AssertionError.class) + public void testNonAsciiAssertion() { + LegacyCommandLineArgumentParser clp = new LegacyCommandLineArgumentParser(new TestParserFail()); + + PrintStream stream = new PrintStream(new NullOutputStream()); + clp.parseArguments(stream, new String[]{}); + clp.usage(stream, true); + } + + @Test + public void testNonAsciiConverted() { + LegacyCommandLineArgumentParser clp = new LegacyCommandLineArgumentParser(new TestParserSucceed()); + + ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); + PrintStream stream = new PrintStream(byteArrayOutputStream); + clp.parseArguments(stream, new String[]{}); + clp.usage(stream, true); + + String expected = "USAGE: TestParserSucceed [options]\n" + + "\n" + + " X < Y This is the first row \n" + + "And this is the second"; + String result = byteArrayOutputStream.toString(); + Assert.assertEquals(byteArrayOutputStream.toString().substring(0, expected.length()), expected); + } + + @Test + public void testNonASCIIAccept() { + LegacyCommandLineArgumentParser.checkForNonASCII("abc", "ascii passes"); + } + + @Test(expectedExceptions = AssertionError.class) + public void testNonASCIIReject() { + LegacyCommandLineArgumentParser.checkForNonASCII("\u0080", "non-ascii fails"); + } + + static private int lengthOfCommonSubstring(String lhs, String rhs) { + int i = 0; + while (i < Math.min(lhs.length(), rhs.length()) && lhs.charAt(i) == rhs.charAt(i)) i++; + + return i; + } + + private class NullOutputStream extends OutputStream { + @Override + public void write(final int b) throws IOException { + + } + } +} diff --git a/src/test/java/org/broadinstitute/barclay/argparser/StrictBooleanConverterTest.java b/src/test/java/org/broadinstitute/barclay/argparser/StrictBooleanConverterTest.java new file mode 100644 index 00000000..bade8808 --- /dev/null +++ b/src/test/java/org/broadinstitute/barclay/argparser/StrictBooleanConverterTest.java @@ -0,0 +1,25 @@ +package org.broadinstitute.barclay.argparser; + +import joptsimple.ValueConversionException; +import org.testng.Assert; +import org.testng.annotations.Test; + + +public final class StrictBooleanConverterTest { + @Test + public void recognizedValues(){ + StrictBooleanConverter converter = new StrictBooleanConverter(); + Assert.assertEquals("true", converter.convert("true")); + Assert.assertEquals("true", converter.convert("T")); + Assert.assertEquals("true",converter.convert("TRUE")); + Assert.assertEquals("false",converter.convert("F")); + Assert.assertEquals("false", converter.convert("False")); + } + + @Test(expectedExceptions = ValueConversionException.class) + public void unrecognizedValues(){ + StrictBooleanConverter converter = new StrictBooleanConverter(); + converter.convert("unprovable"); + } + +} diff --git a/src/test/java/org/broadinstitute/barclay/argparser/TestProgramGroup.java b/src/test/java/org/broadinstitute/barclay/argparser/TestProgramGroup.java new file mode 100644 index 00000000..2a20a349 --- /dev/null +++ b/src/test/java/org/broadinstitute/barclay/argparser/TestProgramGroup.java @@ -0,0 +1,16 @@ +package org.broadinstitute.barclay.argparser; + +/** + * only for testing + */ +public final class TestProgramGroup implements CommandLineProgramGroup { + @Override + public String getName() { + return "Testing"; + } + + @Override + public String getDescription() { + return "group used for testing"; + } +} \ No newline at end of file diff --git a/src/test/java/org/broadinstitute/barclay/utils/UtilsUnitTest.java b/src/test/java/org/broadinstitute/barclay/utils/UtilsUnitTest.java new file mode 100644 index 00000000..d197e6b6 --- /dev/null +++ b/src/test/java/org/broadinstitute/barclay/utils/UtilsUnitTest.java @@ -0,0 +1,33 @@ +package org.broadinstitute.barclay.utils; + +import org.testng.Assert; +import org.testng.annotations.Test; + +import static java.util.Arrays.asList; + +public class UtilsUnitTest { + + @Test(expectedExceptions = IllegalArgumentException.class) + public void testNonNullThrows(){ + final Object o = null; + Utils.nonNull(o); + } + + @Test + public void testNonNullDoesNotThrow(){ + final Object o = new Object(); + Assert.assertSame(Utils.nonNull(o), o); + } + + @Test(expectedExceptions = IllegalArgumentException.class, expectedExceptionsMessageRegExp = "^The exception message$") + public void testNonNullWithMessageThrows() { + Utils.nonNull(null, "The exception message"); + } + + @Test + public void testNonNullWithMessageReturn() { + final Object testObject = new Object(); + Assert.assertSame(Utils.nonNull(testObject, "some message"), testObject); + } + +}