candr.yoclip.Parser.java Source code

Java tutorial

Introduction

Here is the source code for candr.yoclip.Parser.java

Source

/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package candr.yoclip;

import candr.yoclip.annotation.OptionProperties;

import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.SystemUtils;
import org.apache.commons.lang3.text.StrBuilder;
import org.apache.commons.lang3.tuple.ImmutablePair;
import org.apache.commons.lang3.tuple.Pair;

import java.util.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * The options parser implementation.
 *
 * @param <T> The Java type the parser will be associated with.
 */
public class Parser<T> {

    /**
     * The default width for text when it is formatted.
     */
    public static final int DEFAULT_WIDTH = 80;

    /**
     * The width of the formatted help.
     */
    private int width = Parser.DEFAULT_WIDTH;

    /**
     * The minimum width for text when it is formatter.
     */
    public static final int MINIMUM_WIDTH = 25;

    /**
     * The option parameters associated with the parser.
     */
    private final ParserOptions<T> parserOptions;

    /**
     * The options help formatter.
     */
    private final ParserHelpFactory<T> parserHelpFactory;

    /**
     * The collection of {@link OptionProperties} parameter matchers.
     */
    private final List<Pair<Matcher, String>> propertyMatchers;

    /**
     * Make sure there is white space before the help header.
     */
    private boolean spaceBeforeHeader = true;

    /**
     * Make sure there is white space between each options help description.
     */
    private boolean spaceBetweenOptionDescriptions = true;

    /**
     * Make sure there is white space before the help trailer.
     */
    private boolean spaceBeforeTrailer = true;

    /**
     * A custom help message.
     */
    private String help;

    /**
     * A custom usage message.
     */
    private String usage;

    /**
     * Constructs a parser using the {@link ParserOptionsFactory} with it's default configuration.
     *
     * @param clazz The class type associated with the parser.
     */
    public Parser(final Class<T> clazz) {
        this(new ParserOptionsFactory<T>(clazz).create(), new DefaultParserHelpFactory<T>());
    }

    /**
     * Constructs a parser instances.
     *
     * @param parserOptions     The parse parameters manager.
     * @param parserHelpFactory The parser help factory that will be used.
     * @throws java.lang.IllegalArgumentException if parser parameters or parser help factory is null.
     */
    public Parser(final ParserOptions<T> parserOptions, final ParserHelpFactory<T> parserHelpFactory) {

        if (null == parserOptions) {
            throw new IllegalArgumentException("OptionParameters cannot be null.");
        }
        if (null == parserHelpFactory) {
            throw new IllegalArgumentException("OptionHelpFormatter cannot be null.");
        }

        this.parserOptions = parserOptions;
        this.parserHelpFactory = parserHelpFactory;

        final List<Pair<Matcher, String>> matchers = new LinkedList<Pair<Matcher, String>>();
        for (final ParserOption optionParameter : parserOptions.get()) {
            if (optionParameter.isProperties()) {
                final String propertyName = optionParameter.getNames()[0];
                final String regex = getPropertyPattern(propertyName);
                final Matcher matcher = Pattern.compile(regex).matcher("");
                matchers.add(ImmutablePair.of(matcher, propertyName));
            }
        }

        this.propertyMatchers = Collections.unmodifiableList(matchers);
    }

    // properties

    /**
     * Returns a description of the command options and arguments.
     *
     * @return a description of the command options and arguments.
     */
    public String getHelp() {
        if (StringUtils.isEmpty(help)) {
            help = createHelp();
        }
        return help;
    }

    /**
     * Allows a custom help description to be used. Each string will become a line of help output.
     *
     * @param help The custom help description or empty if the default help should be created.
     */
    public void setHelp(final String... help) {
        this.help = (help.length == 0) ? null : StringUtils.join(help, SystemUtils.LINE_SEPARATOR);
    }

    /**
     * Returns a terse description of the command options and arguments. Typically this will be the first line used when help
     * is returned.
     *
     * @return a terse description of the command options and arguments.
     */
    public String getUsage() {
        if (StringUtils.isEmpty(usage)) {
            usage = createUsage();
        }
        return usage;
    }

    /**
     * Allows a custom terse description to be used.
     *
     * @param usage The terse description of the command options and arguments that will be used or empty if the default usage
     *              should be created..
     */
    public void setUsage(final String usage) {
        this.usage = usage;
    }

    /**
     * The width of text when it is formatted.
     *
     * @return the width of text when it is formatted.
     */
    public int getWidth() {
        return width;
    }

    /**
     * Sets the width of text when it is formatted.
     *
     * @param width the width of text when it is formatted.
     */
    public void setWidth(int width) {
        this.width = Math.max(width, Parser.MINIMUM_WIDTH);
    }

    /**
     * Indicates there should be whitespace preceding the help header.
     *
     * @return {@code true} if whitespace should precede the header, {@code false} otherwise;
     */
    public boolean isSpaceBeforeHeader() {
        return spaceBeforeHeader;
    }

    /**
     * Specifies if there should be whitespace preceding the help header.
     *
     * @param spaceBeforeHeader A {@code true} value indicates whitespace should precede the header.
     */
    public void setSpaceBeforeHeader(final boolean spaceBeforeHeader) {
        this.spaceBeforeHeader = spaceBeforeHeader;
    }

    /**
     * Indicates there should be whitespace between option help descriptions.
     *
     * @return {@code true} if whitespace should be between option descriptions, {@code false} otherwise.
     */
    public boolean isSpaceBetweenOptionDescriptions() {
        return spaceBetweenOptionDescriptions;
    }

    /**
     * Specifies if there should be whitespace between option help descriptions.
     *
     * @param spaceBetweenOptionDescriptions A {@code true} value indicates whitespace should be between option descriptions.
     */
    public void setSpaceBetweenOptionDescriptions(final boolean spaceBetweenOptionDescriptions) {
        this.spaceBetweenOptionDescriptions = spaceBetweenOptionDescriptions;
    }

    /**
     * Indicates there should be whitespace preceding the help trailer.
     *
     * @return {@code true} if whitespace should precede the trailer, {@code false} otherwise.
     */
    public boolean isSpaceBeforeTrailer() {
        return spaceBeforeTrailer;
    }

    /**
     * Specifies if there should be whitespace preceding the help trailer.
     *
     * @param spaceBeforeTrailer A {@code true} value indicates whitespace should precede the trailer.
     */
    public void setSpaceBeforeTrailer(final boolean spaceBeforeTrailer) {
        this.spaceBeforeTrailer = spaceBeforeTrailer;
    }

    /**
     * Get the options parameters associated with the parser.
     *
     * @return the options parameters manager.
     */
    protected ParserOptions<T> getParserOptions() {
        return parserOptions;
    }

    /**
     * Returns the "live" option property matchers.
     *
     * @return the collection of option property matchers.
     */
    protected List<Pair<Matcher, String>> getPropertyMatchers() {
        return propertyMatchers;
    }

    /**
     * Returns the {@code OptionHelpFormatter} configured for the option parser.
     *
     * @return the {@code OptionHelpFormatter} used by the option parser.
     */
    protected ParserHelpFactory<T> getParserHelpFactory() {
        return parserHelpFactory;
    }

    // API

    public ParseResult<T> parse(final T bean, String... parameters) {

        final List<ParsedOption<T>> parsedOptions = getParsedParameters(parameters);

        final ParseResult<T> parseResult = new ParseResult<T>(bean);
        if (!hasHelpParameter(bean, parsedOptions)) {

            verifyParse(parseResult, parsedOptions);
            if (!parseResult.isParseError()) {

                for (final ParsedOption<T> parsedOption : parsedOptions) {

                    final ParserOption<T> parserOption = parsedOption.getParserOption();

                    if (parserOption.isProperties()) {
                        setOptionProperty(bean, parserOption, parsedOption.getValue());

                    } else if (parserOption.isArguments()) {
                        setArguments(bean, parserOption, parsedOption.getValue());

                    } else {
                        setOption(bean, parserOption, parsedOption.getValue());
                    }
                }
            }
        }

        return parseResult;
    }

    protected void verifyParse(final ParseResult<T> parseResult, final List<ParsedOption<T>> parsedOptions) {
        checkForParseError(parseResult, parsedOptions);
        if (!parseResult.isParseError()) {
            checkForDuplicateParameters(parseResult, parsedOptions);
            if (!parseResult.isParseError()) {
                checkForRequiredParameters(parseResult, parsedOptions);
            }
        }
    }

    protected void checkForParseError(final ParseResult<T> parseResult, final List<ParsedOption<T>> parsedOptions) {
        for (final ParsedOption<T> parsedOption : parsedOptions) {
            if (parsedOption.isError()) {
                parseResult.addError(parsedOption);
            }
        }
    }

    protected void checkForDuplicateParameters(final ParseResult<T> parseResult,
            final List<ParsedOption<T>> parsedOptions) {

        final Set<String> parsedParameterNames = new HashSet<String>();
        for (final ParsedOption<T> parsedOption : parsedOptions) {

            final ParserOption<T> parserOption = parsedOption.getParserOption();
            final String parserParameterId = parserOption.toString();
            if (!parsedParameterNames.contains(parserParameterId)) {
                parsedParameterNames.add(parserParameterId);

            } else if (!parserOption.isArguments() && !parserOption.isProperties()) {
                final ParsedOption<T> error = new ParsedOption<T>(parserOption, null);
                error.setError("Option used multiple times.");
                parseResult.addError(error);
            }
        }
    }

    protected void checkForRequiredParameters(final ParseResult<T> parseResult,
            final List<ParsedOption<T>> parsedOptions) {
        final Set<String> parsedParameterNames = new HashSet<String>();
        for (final ParsedOption<T> parsedOption : parsedOptions) {
            final String parsedParameterId = parsedOption.getParserOption().toString();
            parsedParameterNames.add(parsedParameterId);
        }

        // make sure required parameters are provided
        for (final ParserOption<T> parserOption : getParserOptions().get()) {
            if (parserOption.isRequired()) {
                final String parserParameterId = parserOption.toString();
                if (!parsedParameterNames.contains(parserParameterId)) {
                    final ParsedOption<T> error = new ParsedOption<T>(parserOption, null);
                    error.setError("Option required.");
                    parseResult.addError(error);
                }
            }
        }

        // make sure required arguments are provided
        final ParserOption<T> parserArguments = getParserOptions().getArguments();
        if (null != parserArguments) {
            if (parserArguments.isRequired()) {
                final String parserArgumentsId = parserArguments.toString();
                if (!parsedParameterNames.contains(parserArgumentsId)) {
                    final ParsedOption<T> error = new ParsedOption<T>(parserArguments, null);
                    error.setError("Arguments required.");
                    parseResult.addError(error);
                }
            }
        }
    }

    /**
     * Creates the pattern used to identify a parameter as an option property.
     *
     * @param propertyName The name of the option property parameter.
     * @return the pattern used to identify an option property parameter.
     */
    protected String getPropertyPattern(final String propertyName) {
        return new StrBuilder().append(propertyName).append("(").append("[\\p{Alnum}*$._-]+")
                .append(OptionProperties.KEY_VALUE_SEPARATOR).append("\\p{Graph}+").append(")").toString();
    }

    /**
     * Tests if the argument starts with the configured option prefix. See {@link ParserOptions#getPrefix()} for more information.
     *
     * @param arg The argument being tested.
     * @return {@code true} if the arguments starts with the option prefix, {@code false} otherwise.
     */
    protected boolean isOption(final String arg) {

        return arg.startsWith(getParserOptions().getPrefix());
    }

    /**
     * Creates a collection of parsed option parameters from the queue of parameters passed in. The parameters queue is modified as parameters are
     * parsed, removing option parameters from the queue as they are parsed.
     *
     * @param parameters The command parameters that will be parsed.
     * @return a collection of parsed option parameters or {@code null} if the parameters queue is empty.
     */
    protected List<ParsedOption<T>> getParsedParameters(final String[] parameters) {

        final LinkedList<ParsedOption<T>> parsedOptionParameters = new LinkedList<ParsedOption<T>>();

        final Queue<String> parametersQueue = new LinkedList<String>(Arrays.asList(parameters));
        while (!parametersQueue.isEmpty()) {

            ParsedOption<T> parsedOptionParameter;
            if (!isOption(parametersQueue.peek())) {

                parsedOptionParameter = getParsedArgument(parametersQueue);

            } else {

                // check for an option property match first
                parsedOptionParameter = getParsedOptionProperty(parametersQueue);

                // an option next
                if (null == parsedOptionParameter) {
                    parsedOptionParameter = getParsedOption(parametersQueue);
                }
            }

            // not an option
            if (null == parsedOptionParameter) {
                final String parameter = parametersQueue.remove();
                parsedOptionParameter = new ParsedOption<T>("'" + parameter + "': Unsupported option.");
            }

            parsedOptionParameters.add(parsedOptionParameter);

        }

        return parsedOptionParameters;
    }

    /**
     * Creates a {@code ParsedOptionParameter} for the option parameter at the head of the queue. The parsed option will not contain an error if an
     * option value is missing. The parsed option will contain an error if the option appears to have an associated value and does not take a value.
     *
     * @param parameters The current queue of command parameters.
     * @return a parsed option parameter or {@code null} in the following cases.
     * <ul>
     * <li>The parameters queue is empty.</li>
     * <li>The head of the parameters queue is not an option.</li>
     * </ul>
     */
    protected ParsedOption<T> getParsedOption(final Queue<String> parameters) {

        ParsedOption<T> parsedOptionParameter = null;

        final String parameter = parameters.peek();
        if (!StringUtils.isEmpty(parameter) && isOption(parameter)) {

            final String prefix = getParserOptions().getPrefix();
            final String separator = getParserOptions().getSeparator();
            final boolean isSeparatorWhitespace = StringUtils.isWhitespace(separator);

            final int separatorIndex = isSeparatorWhitespace ? -1 : parameter.indexOf(separator);
            final String optionParameterKey = parameter.substring(prefix.length(),
                    separatorIndex < 0 ? parameter.length() : separatorIndex);
            final ParserOption<T> optionParameter = getParserOptions().get(optionParameterKey);
            if (null != optionParameter) {

                parameters.remove();

                // get the value if the option takes one
                if (optionParameter.hasValue()) {

                    String value = null;
                    if (isSeparatorWhitespace) {

                        if (parameters.size() > 0 && !isOption(parameters.peek())) {

                            // remove the value from the queue
                            value = parameters.remove();
                        }

                    } else if (separatorIndex != -1) {

                        final int valueIndex = separatorIndex + 1;
                        if (valueIndex < parameter.length()) {
                            value = parameter.substring(valueIndex);
                        }
                    }

                    // The value can be null here, without it being an error condition, to facilitate actions later on
                    // such as using a default.
                    parsedOptionParameter = new ParsedOption<T>(optionParameter, value);

                } else if (separatorIndex > 1) {

                    // if the separator is not white space and a value was present with the option parameter
                    parsedOptionParameter = new ParsedOption<T>(optionParameter, null);
                    parsedOptionParameter.setError("Does not take a value.");

                } else {

                    // If the option does not take a value it must be a boolean so force it true
                    parsedOptionParameter = new ParsedOption<T>(optionParameter, Boolean.TRUE.toString());
                }
            }
        }

        return parsedOptionParameter;
    }

    /**
     * Called to set an option value into a bean.
     *
     * @param bean         The bean instance that will be updated.
     * @param parserOption The beans option.
     * @param value        The value that will be set into the bean.
     */
    protected void setOption(final T bean, final ParserOption<T> parserOption, final String value) {
        if (parserOption.isArguments() || parserOption.isProperties()) {
            throw new IllegalArgumentException("ParserOption is not an option.");
        }
        parserOption.setOption(bean, value);
    }

    /**
     * Creates a {@code ParsedOptionParameter} for the option property parameter at the head of the queue. The value of the parsed option will
     * contain option property key and value. As an example if the parameters queue head contains "<code>-Dkey=value</code>" the parsed option value
     * will be the string "<code>key=value</code>" (assuming the bean contains a field annotated with {@link candr.yoclip.annotation.OptionProperties
     * OptionProperties}).
     *
     * @param parameters The current queue of command parameters.
     * @return a parsed option parameter or {@code null} in the following cases.
     * <ul>
     * <li>The parameters queue is empty.</li>
     * <li>The bean does not contain option property annotations.</li>
     * <li>The head of the parameters queue does not match an option property annotation on the bean.</li>
     * </ul>
     */
    protected ParsedOption<T> getParsedOptionProperty(final Queue<String> parameters) {

        ParsedOption<T> parsedOption = null;

        String parameter = parameters.peek();
        if (!StringUtils.isEmpty(parameter)) {

            final String prefix = getParserOptions().getPrefix();
            if (parameter.startsWith(prefix)) {

                parameter = parameter.substring(prefix.length());
                for (final Pair<Matcher, String> propertyMatcher : getPropertyMatchers()) {

                    final Matcher matcher = propertyMatcher.getLeft();
                    if (matcher.reset(parameter).matches()) {

                        parameters.remove();

                        // this always succeeds because the parser has set up the property matcher
                        final String optionParametersKey = propertyMatcher.getRight();
                        final ParserOption<T> optionParameter = getParserOptions().get(optionParametersKey);
                        final String value = matcher.group(1);
                        parsedOption = new ParsedOption<T>(optionParameter, value);

                        break;
                    }
                }
            }
        }

        return parsedOption;
    }

    /**
     * Called to set an option property into a bean.
     *
     * @param bean         The bean instance that will be updated.
     * @param parserOption The beans option property.
     * @param value        The value that will be set into the bean property.
     */
    protected void setOptionProperty(final T bean, final ParserOption<T> parserOption, final String value) {
        if (!parserOption.isProperties()) {
            throw new IllegalArgumentException(parserOption + " ParserOption must be properties.");
        }
        parserOption.setOption(bean, value);
    }

    /**
     * Creates a {@code ParsedOptionParameter} for the argument at the head of the queue. The value of the parsed option will contain the argument
     * value. The parsed option parameter will contain an error if the bean does not have an arguments annotation..
     *
     * @param parameters The current queue of command parameters.
     * @return a parsed option parameter or {@code null} if the parameters queue is empty.
     */
    protected ParsedOption<T> getParsedArgument(final Queue<String> parameters) {

        ParsedOption<T> parsedOption = null;

        if (!parameters.isEmpty()) {

            final ParserOption<T> arguments = getParserOptions().getArguments();
            final String parameter = parameters.remove();

            if (null != arguments) {
                parsedOption = new ParsedOption<T>(arguments, parameter);
            } else {
                parsedOption = new ParsedOption<T>(getParserOptions().getName() + " does not have arguments.");
            }
        }

        return parsedOption;
    }

    /**
     * Called to set an argument into a bean.
     *
     * @param bean         The bean instance that will be updated.
     * @param parserOption The beans argument option.
     * @param argument     The value that will be set into the bean argument.
     */
    protected void setArguments(final T bean, final ParserOption<T> parserOption, final String argument) {
        if (!parserOption.isArguments()) {
            throw new IllegalArgumentException(parserOption + " ParserOption must be arguments.");
        }
        parserOption.setOption(bean, argument);
    }

    /**
     * Searches the parameters to determine if help is being requested. There are not other checks performed. If a help option parameter is found the
     * associated bean is set {@code true}.
     *
     * @param bean                   The bean associated with the option parameters being parsed.
     * @param parsedOptionParameters The parsed command line option parameters.
     * @return {@code true} if the parameters contain a help option, {@code false} otherwise.
     */
    protected boolean hasHelpParameter(final T bean, List<ParsedOption<T>> parsedOptionParameters) {

        boolean hasHelpParameter = false;

        for (final ParsedOption<T> parsedOptionParameter : parsedOptionParameters) {
            if (!parsedOptionParameter.isError()) {

                final ParserOption<T> optionParameter = parsedOptionParameter.getParserOption();
                if (optionParameter.isHelp()) {
                    setOption(bean, optionParameter, Boolean.TRUE.toString());
                    hasHelpParameter = true;
                    break;
                }
            }
        }

        return hasHelpParameter;
    }

    protected String createHelp() {

        final ParserOptions<T> parserOptions = getParserOptions();
        final ParserHelpFactory<T> parserHelpFactory = getParserHelpFactory();

        final StrBuilder builder = new StrBuilder();

        // the usage synopsis is first
        builder.append("Usage: ").append(parserOptions.getName()).append(' ').appendln(getUsage());

        // followed by the header if one is available
        final String header = parserHelpFactory.getHeaderDescription(parserOptions);
        if (!StringUtils.isEmpty(header)) {

            if (isSpaceBeforeHeader()) {
                builder.appendNewLine();
            }

            builder.appendln(parserHelpFactory.wrap(header, getWidth()));
        }

        // followed by the option descriptions
        final String prefix = parserOptions.getPrefix();
        final String separator = parserOptions.getSeparator();

        // get all the options descriptions
        int hangingIndentSize = 0;
        final List<Pair<Boolean, Pair<String, String>>> optionDescriptionWrappers = new LinkedList<Pair<Boolean, Pair<String, String>>>();
        for (final ParserOption<T> parserOption : parserOptions.get()) {

            final Pair<String, String> optionDescription = parserHelpFactory.getOptionDescription(prefix, separator,
                    parserOption);
            optionDescriptionWrappers.add(ImmutablePair.of(true, optionDescription));
            hangingIndentSize = Math.max(hangingIndentSize, optionDescription.getLeft().length());

            for (final Pair<String, String> optionPropertyDescription : parserHelpFactory
                    .getOptionPropertyDescriptions(parserOption)) {
                optionDescriptionWrappers.add(ImmutablePair.of(false, optionPropertyDescription));
            }
        }

        // now add the help to the builder
        int maxIndentSize = getWidth() / 4;
        hangingIndentSize = Math.min(hangingIndentSize + 2, maxIndentSize);
        for (final Pair<Boolean, Pair<String, String>> optionDescriptionWrapper : optionDescriptionWrappers) {

            if (isSpaceBetweenOptionDescriptions()) {
                builder.appendNewLine();
            }

            // create the option description or option property description
            final StrBuilder descriptionBuilder = new StrBuilder();
            if (optionDescriptionWrapper.getLeft()) {

                final Pair<String, String> optionDescription = optionDescriptionWrapper.getRight();
                descriptionBuilder.append(StringUtils.rightPad(optionDescription.getLeft(), hangingIndentSize));
                if (descriptionBuilder.length() > hangingIndentSize) {
                    descriptionBuilder.append(" ");
                }
                descriptionBuilder.append(optionDescription.getRight());

            } else {

                final Pair<String, String> optionPropertyDescription = optionDescriptionWrapper.getRight();
                descriptionBuilder.appendPadding(hangingIndentSize, ' ').append("'")
                        .append(optionPropertyDescription.getLeft()).append("' ")
                        .append(optionPropertyDescription.getRight());
            }
            builder.appendln(parserHelpFactory.hangingIndentWrap(descriptionBuilder.toString(), hangingIndentSize,
                    getWidth()));
        }

        // followed by the trailer if one is available
        final String trailer = parserHelpFactory.getTrailerDescription(parserOptions);
        if (!StringUtils.isEmpty(trailer)) {

            if (isSpaceBeforeTrailer()) {
                builder.appendNewLine();
            }

            builder.appendln(parserHelpFactory.wrap(trailer, getWidth()));
        }

        return builder.toString();
    }

    protected String createUsage() {
        return getParserHelpFactory().createUsage(getParserOptions());
    }

    @Override
    public String toString() {
        return getParserOptions().toString();
    }
}