com.hablutzel.cmdline.CommandLineApplication.java Source code

Java tutorial

Introduction

Here is the source code for com.hablutzel.cmdline.CommandLineApplication.java

Source

/**
 * Copyright (c) Bob Hablutzel. All rights reserved.
 *
 * This code is released under a simplified BSD license.
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are met:
 *
 * 1. Redistributions of source code must retain the above copyright notice, this
 *    list of conditions and the following disclaimer.
 * 2. 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.
 *
 * 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 OWNER 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.
 *
 */

package com.hablutzel.cmdline;

import org.apache.commons.beanutils.ConvertUtils;
import org.apache.commons.beanutils.Converter;
import org.apache.commons.cli.*;

import java.lang.reflect.Array;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

/**
 * Created by Bob Hablutzel on 6/10/16.
 */
public class CommandLineApplication {

    private enum MethodType {
        Boolean, Scalar, Array, List
    };

    /**
     * Private class used to remember configuration values
     * for the methods used with the command line options
     */
    private static final class CommandLineMethodHelper {
        Method method;
        MethodType methodType;
        Class<?> elementType;
        Converter converter;

        CommandLineMethodHelper(Method method, MethodType methodType, Class<?> elementType, Converter converter) {
            this.method = method;
            this.methodType = methodType;
            this.elementType = elementType;
            this.converter = converter;
        }

        // Invokes the method. If any invocation returns false, then
        // the looping stops and false is returned. Otherwise this method
        // returns true. Note that we've already validated that the only
        // possible return is a boolean

        boolean invokeMethod(Object instance, String[] arguments) throws CommandLineException {
            boolean continueToInvoke = true;
            try {
                switch (methodType) {
                case Boolean: {
                    Object result = method.invoke(instance);
                    if (result instanceof Boolean) {
                        continueToInvoke = ((Boolean) result);
                    }
                    break;
                }
                case Scalar: {
                    if (arguments == null) {
                        method.invoke(instance, new Object[] { null });
                    } else {
                        for (String s : arguments) {
                            Object result = method.invoke(instance, converter.convert(elementType, s));
                            if (result instanceof Boolean) {
                                continueToInvoke = ((Boolean) result);
                            }
                            if (!continueToInvoke)
                                break;
                        }
                    }
                    break;
                }
                case Array: {
                    Object result;
                    if (arguments == null) {
                        result = method.invoke(instance, Array.newInstance(elementType, 0));
                    } else {
                        Object array = Array.newInstance(elementType, arguments.length);
                        for (int i = 0; i < arguments.length; ++i) {
                            Array.set(array, i, converter.convert(elementType, arguments[i]));
                        }
                        result = method.invoke(instance, array);
                    }
                    if (result instanceof Boolean) {
                        continueToInvoke = ((Boolean) result);
                    }
                    break;
                }
                case List: {
                    Object result;
                    if (arguments == null) {
                        result = method.invoke(instance, new ArrayList());
                    } else {
                        List list = new ArrayList();
                        for (int i = 0; i < arguments.length; ++i) {
                            list.add(converter.convert(elementType, arguments[i]));
                        }
                        result = method.invoke(instance, list);
                    }
                    if (result instanceof Boolean) {
                        continueToInvoke = ((Boolean) result);
                    }
                    break;
                }
                }
            } catch (InvocationTargetException e) {
                throw new CommandLineException("Unable to invoke method " + method.getName(), e);
            } catch (IllegalAccessException e) {
                throw new CommandLineException(
                        "Unable to invoke method " + method.getName() + " because the method is not accessible");
            }

            return continueToInvoke;
        }

    }

    /**
     * Common-cli options for command line parsing
     */
    private Options options = new Options();

    /**
     * Map of options to configurations
     */
    private Map<Option, CommandLineMethodHelper> optionHelperMap = new HashMap<>();

    /**
     * Helper for the main command line method
     */
    private CommandLineMethodHelper mainHelper = null;

    /**
     * This method scans the subclass for annotations
     * that denote the command line options and arguments,
     * and configures the systems so that the members that
     * have been annotated in that way are set up for calling
     * at command line processing time
     *
     */
    private final void configure() throws CommandLineException {

        // Find all the fields in our subclass
        for (Method method : this.getClass().getDeclaredMethods()) {

            // If this method is marked with a  command line option, then configure
            // a corresponding commons-cli command line option here
            if (method.isAnnotationPresent(CommandLineOption.class)) {
                CommandLineOption commandLineOption = method.getDeclaredAnnotation(CommandLineOption.class);
                if (commandLineOption != null) {

                    // Get the basic information about the option - the name and description
                    String shortName = commandLineOption.shortForm().equals("") ? null
                            : commandLineOption.shortForm();
                    String longName = commandLineOption.longForm().equals("") ? null : commandLineOption.longForm();
                    String description = commandLineOption.usage();

                    // If both the short and long name are null, then use the field name as the long name
                    if (shortName == null && longName == null) {
                        longName = method.getName();
                    }

                    // The signature of the method determines what kind of command line
                    // option is allowed. Basically, if the method does not take an argument,
                    // then the option does not take arguments either. In this case, the
                    // method is just called when the option is present.
                    //
                    // If the method does take argument, there are restrictions on the arguments
                    // that are allowed. If there is a single argument, then the method will be
                    // called for each argument supplied to the option. Generally in this case you
                    // want the maximum number of option arguments to be 1, and you are just getting
                    // the value of the argument. On the other hand, if the single argument is either
                    // and array or a List<>, then the arguments will be passed in as an argument
                    // or list respectively.
                    //
                    // Methods with more than 1 argument are not allowed. Methods with return types
                    // other than boolean are not allowed. Methods that throw an exception other than
                    // org.apache.commons.cli.CommandLineException are not allowed,
                    //
                    // If the method returns a boolean, and calling that method returns FALSE, then the
                    // command line main function will not be called.
                    //
                    // The class of the argument has to be convertable using common-beanutils
                    // conversion facilities
                    CommandLineMethodHelper helper = getHelperForCommandOption(method, commandLineOption);

                    // Now create and configure an option based on what the method is capable of handling
                    // and the command line option parameters
                    boolean allowsArguments = helper.methodType != MethodType.Boolean;
                    Option option = new Option(shortName, longName, allowsArguments, description);

                    // Configure it
                    option.setRequired(commandLineOption.required());
                    if (option.hasArg()) {
                        option.setType(helper.elementType);
                        option.setArgs(commandLineOption.maximumArgumentCount());
                        option.setValueSeparator(commandLineOption.argumentSeparator());
                        option.setOptionalArg(commandLineOption.optionalArgument());
                    }

                    // Remember it, both in the commons-cli options set and
                    // in our list of elements for later post-processing
                    options.addOption(option);
                    optionHelperMap.put(option, helper);
                }

                // This was not a command line option method - is it the main command line method?
            } else if (method.isAnnotationPresent(CommandLineMain.class)) {

                // Make sure we only have one
                if (mainHelper != null) {
                    throw new CommandLineException("Cannot have two main methods specified");
                } else {
                    mainHelper = getHelperForCommandLineMain(method);
                }
            }
        }
    }

    /**
     * Validate a Method to be a main command line application method.
     *
     * Methods with more than 1 argument are not allowed. Methods with return types
     * are not allowed. Methods that throw an exception other than
     * org.apache.commons.cli.CommandLineException are not allowed,
     *
     * @param method the method to validate
     * @return A new method helper for the method
     */
    private CommandLineMethodHelper getHelperForCommandLineMain(Method method) throws CommandLineException {

        // Validate that the return type is a void
        if (!method.getReturnType().equals(Void.TYPE)) {
            throw new CommandLineException("For method " + method.getName() + ", the return type is not void");
        }

        // Validate the exceptions throws by the method
        for (Class<?> clazz : method.getExceptionTypes()) {
            if (!clazz.equals(CommandLineException.class)) {
                throw new CommandLineException("For method " + method.getName()
                        + ", there is an invalid exception class " + clazz.getName());
            }
        }

        // In order to get ready to create the configuration instance,
        // we will need to know the command line option type
        // and the element type.
        Class<?> elementClass;
        MethodType methodType;
        Converter converter;

        // Get the parameters of the method. We'll use these to
        // determine what type of option we have - scalar, boolean, etc.
        Class<?> parameterClasses[] = method.getParameterTypes();

        // See what the length tells us
        switch (parameterClasses.length) {
        case 0:
            throw new CommandLineException("Main command line method must take arguments");
        case 1: {

            // For a method with one argument, we have to look
            // more closely at the argument. It has to be a simple
            // scalar object, an array, or a list.
            Class<?> parameterClass = parameterClasses[0];
            if (parameterClass.isArray()) {

                // For an array, we get the element class based on the
                // underlying component type
                methodType = MethodType.Array;
                elementClass = parameterClass.getComponentType();
            } else {

                // For a scalar, we get the element type from the
                // type of the parameter.
                methodType = MethodType.Scalar;
                elementClass = parameterClass.getClass();
            }

            // Now that we have the element type, make sure it's convertable
            converter = ConvertUtils.lookup(String.class, elementClass);
            if (converter == null) {
                throw new CommandLineException("Cannot find a conversion from String to " + elementClass.getName()
                        + " for method " + method.getName());
            }
            break;
        }
        default: {

            // Other method types not allowed.
            throw new CommandLineException("Method " + method.getName() + " has too many arguments");
        }
        }

        // Now we can return the configuration for this method
        return new CommandLineMethodHelper(method, methodType, elementClass, converter);
    }

    /**
     * Validate a Method to be a command line option methods.
     *
     * Methods with more than 1 argument are not allowed. Methods with return types
     * other than boolean are not allowed. Methods that throw an exception other than
     * org.apache.commons.cli.CommandLineException are not allowed,
     *
     * @param method the method to validate
     * @param commandLineOption the options on that method
     * @return A new method helper for the method
     */
    private CommandLineMethodHelper getHelperForCommandOption(Method method, CommandLineOption commandLineOption)
            throws CommandLineException {

        // Validate that the return type is a boolean or void
        if (!method.getReturnType().equals(Boolean.TYPE) && !method.getReturnType().equals(Void.TYPE)) {
            throw new CommandLineException(
                    "For method " + method.getName() + ", the return type is not boolean or void");
        }

        // Validate the exceptions throws by the method
        for (Class<?> clazz : method.getExceptionTypes()) {
            if (!clazz.equals(CommandLineException.class)) {
                throw new CommandLineException("For method " + method.getName()
                        + ", there is an invalid exception class " + clazz.getName());
            }
        }

        // In order to get ready to create the configuration instance,
        // we will need to know the command line option type
        // and the element type.
        Class<?> elementClass = null;
        MethodType methodType;
        Converter converter;

        // Get the parameters of the method. We'll use these to
        // determine what type of option we have - scalar, boolean, etc.
        Class<?> parameterClasses[] = method.getParameterTypes();

        // See what the length tells us
        switch (parameterClasses.length) {
        case 0:
            methodType = MethodType.Boolean;
            converter = null;
            break;
        case 1: {

            // For a method with one argument, we have to look
            // more closely at the argument. It has to be a simple
            // scalar object, an array, or a list.
            Class<?> parameterClass = parameterClasses[0];
            if (parameterClass.isArray()) {

                // For an array, we get the element class based on the
                // underlying component type
                methodType = MethodType.Array;
                elementClass = parameterClass.getComponentType();
            } else if (List.class.isAssignableFrom(parameterClass)) {

                // For a list, we get the element class from the command
                // line options annotation
                methodType = MethodType.List;
                elementClass = commandLineOption.argumentType();
            } else {

                // For a scalar, we get the element type from the
                // type of the parameter.
                methodType = MethodType.Scalar;
                elementClass = parameterClass.getClass();
            }

            // Now that we have the element type, make sure it's convertable
            converter = ConvertUtils.lookup(String.class, elementClass);
            if (converter == null) {
                throw new CommandLineException("Cannot find a conversion from String to " + elementClass.getName()
                        + " for method " + method.getName());
            }
            break;
        }
        default: {

            // Other method types not allowed.
            throw new CommandLineException("Method " + method.getName() + " has too many arguments");
        }
        }

        // Now we can return the configuration for this method
        return new CommandLineMethodHelper(method, methodType, elementClass, converter);
    }

    /**
     * Method for running the command line application.
     *
     * @param args The arguments passed into main()
     * @throws CommandLineException
     */
    public void parseAndRun(String args[]) throws CommandLineException {

        // Configure our environment
        configure();

        // Make sure there is a main helper
        if (mainHelper == null) {
            throw new CommandLineException("You must specify the main method with @CommandLineMain");
        }
        CommandLine line = null;
        CommandLineParser parser = null;

        try {
            // Parse the command line
            parser = new DefaultParser();
            line = parser.parse(options, args);
        } catch (ParseException e) {
            throw new CommandLineException("Unable to parse command line", e);
        } finally {
            parser = null;
        }

        // Assume we're continuing
        boolean runMain = true;

        // Loop through all our known options
        for (Map.Entry<Option, CommandLineMethodHelper> entry : optionHelperMap.entrySet()) {

            // See if this option was specified
            Option option = entry.getKey();
            CommandLineMethodHelper helper = entry.getValue();
            boolean present = option.getOpt() == null || option.getOpt().equals("")
                    ? line.hasOption(option.getLongOpt())
                    : line.hasOption(option.getOpt());
            if (present) {

                // The user specified this option. Now we have to handle the
                // values, if it has any
                if (option.hasArg()) {
                    String[] arguments = option.getOpt() == null || option.getOpt().equals("")
                            ? line.getOptionValues(option.getLongOpt())
                            : line.getOptionValues(option.getOpt());
                    runMain = helper.invokeMethod(this, arguments) && runMain;
                } else {
                    runMain = helper.invokeMethod(this, new String[] {}) && runMain;
                }
            }
        }

        // Now handle all the extra arguments. In order to clean up memory,
        // we get rid of the structures that we needed in order to parse
        if (runMain) {

            // Get a reference to the arguments
            String[] arguments = line.getArgs();

            // Clean up the parsing variables
            line = null;
            optionHelperMap = null;

            // Now call the main method. This means we have to keep
            // the main helper around, but that's small
            mainHelper.invokeMethod(this, arguments);
        }
    }

    /**
     * Helper function to print the command line usage
     *
     * @param appName Name of the application
     * @param header Header line
     * @param footer Footer line
     */
    public void printCommandLineUsageText(String appName, String header, String footer) {
        HelpFormatter formatter = new HelpFormatter();
        formatter.printHelp(appName, header, options, footer, true);
    }
}