Java tutorial
/* * 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(); } }