/* Copyright 2013 Jonatan Jönsson * * Licensed 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 se.softhouse.jargo; import static com.google.common.base.Preconditions.checkArgument; import static com.google.common.collect.Collections2.filter; import static com.google.common.collect.Lists.newArrayListWithCapacity; import static com.google.common.collect.Maps.newHashMap; import static com.google.common.collect.Maps.newHashMapWithExpectedSize; import static com.google.common.collect.Sets.newHashSetWithExpectedSize; import static com.google.common.collect.Sets.newLinkedHashSetWithExpectedSize; import static java.util.Collections.emptySet; import static se.softhouse.common.guavaextensions.Preconditions2.checkNulls; import static se.softhouse.common.strings.Describables.format; import static se.softhouse.common.strings.StringsUtil.NEWLINE; import static se.softhouse.common.strings.StringsUtil.TAB; import static se.softhouse.common.strings.StringsUtil.startsWithAndHasMore; import static se.softhouse.jargo.Argument.IS_OF_VARIABLE_ARITY; import static se.softhouse.jargo.Argument.IS_REQUIRED; import static se.softhouse.jargo.Argument.ParameterArity.NO_ARGUMENTS; import static se.softhouse.jargo.ArgumentBuilder.DEFAULT_SEPARATOR; import static se.softhouse.jargo.ArgumentExceptions.forMissingArguments; import static se.softhouse.jargo.ArgumentExceptions.forUnallowedRepetitionArgument; import static se.softhouse.jargo.ArgumentExceptions.withMessage; import static se.softhouse.jargo.ArgumentExceptions.wrapException; import static se.softhouse.jargo.CommandLineParser.US_BY_DEFAULT; import java.io.File; import java.io.IOException; import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Map.Entry; import java.util.Set; import javax.annotation.CheckReturnValue; import javax.annotation.Nonnull; import javax.annotation.Nullable; import javax.annotation.concurrent.Immutable; import javax.annotation.concurrent.NotThreadSafe; import javax.annotation.concurrent.ThreadSafe; import se.softhouse.common.collections.CharacterTrie; import se.softhouse.common.strings.StringsUtil; import se.softhouse.jargo.ArgumentExceptions.UnexpectedArgumentException; import se.softhouse.jargo.StringParsers.InternalStringParser; import se.softhouse.jargo.internal.Texts.ProgrammaticErrors; import se.softhouse.jargo.internal.Texts.UsageTexts; import se.softhouse.jargo.internal.Texts.UserErrors; import com.google.common.base.Charsets; import com.google.common.base.Joiner; import com.google.common.collect.Iterables; import com.google.common.collect.Lists; import com.google.common.collect.Sets; import com.google.common.collect.UnmodifiableIterator; import com.google.common.io.Files; /** * A snapshot view of a {@link CommandLineParser} configuration. */ @Immutable final class CommandLineParserInstance { /** * A list where arguments created without names is put */ @Nonnull private final List<Argument<?>> indexedArguments; /** * A map containing short-named arguments, long-named arguments and named arguments that ignore * case */ @Nonnull private final NamedArguments namedArguments; /** * Stores arguments that either has a special {@link ArgumentBuilder#separator()}, or is a * {@link ArgumentBuilder#asPropertyMap()}. Handles {@link ArgumentBuilder#ignoreCase()} as * well. */ @Nonnull private final SpecialArguments specialArguments; /** * Stored separately (as well) as {@link Command}s need access to them through the * {@link ArgumentIterator}. This avoids traversing all named arguments for each parse * checking for help argument definitions */ @Nonnull private final Map<String, Argument<?>> helpArguments; @Nonnull private final Set<Argument<?>> allArguments; /** * Used by {@link Command} to indicate that this parser is part of a {@link Command} */ private final boolean isCommandParser; private final ProgramInformation programInformation; private final Locale locale; CommandLineParserInstance(Iterable<Argument<?>> argumentDefinitions, ProgramInformation information, Locale locale, boolean isCommandParser) { int nrOfArgumentsToHandle = Iterables.size(argumentDefinitions); this.indexedArguments = newArrayListWithCapacity(nrOfArgumentsToHandle); this.namedArguments = new NamedArguments(nrOfArgumentsToHandle); this.specialArguments = new SpecialArguments(); this.helpArguments = newHashMap(); this.allArguments = newLinkedHashSetWithExpectedSize(nrOfArgumentsToHandle); this.programInformation = information; this.locale = locale; this.isCommandParser = isCommandParser; for(Argument<?> definition : argumentDefinitions) { addArgumentDefinition(definition); } verifyThatIndexedAndRequiredArgumentsWasGivenBeforeAnyOptionalArguments(); verifyUniqueMetasForRequiredAndIndexedArguments(); verifyThatOnlyOneArgumentIsOfVariableArity(); } CommandLineParserInstance(Iterable<Argument<?>> argumentDefinitions) { this(argumentDefinitions, ProgramInformation.AUTO, US_BY_DEFAULT, false); } private void addArgumentDefinition(final Argument<?> definition) { if(definition.isIndexed()) { indexedArguments.add(definition); } else { for(String name : definition.names()) { addNamedArgumentDefinition(name, definition); } } boolean added = allArguments().add(definition); checkArgument(added, ProgrammaticErrors.UNIQUE_ARGUMENT, definition); } private void addNamedArgumentDefinition(final String name, final Argument<?> definition) { Argument<?> oldDefinition = null; String separator = definition.separator(); if(definition.isPropertyMap()) { oldDefinition = specialArguments.put(name, definition); } else if(separator.equals(DEFAULT_SEPARATOR)) { oldDefinition = namedArguments.put(name, definition); } else { oldDefinition = specialArguments.put(name + separator, definition); } if(definition.isHelpArgument()) { helpArguments.put(name, definition); } checkArgument(oldDefinition == null, ProgrammaticErrors.NAME_COLLISION, name); } /** * Specifying the optional argument before the required argument would make the optional * argument required */ private void verifyThatIndexedAndRequiredArgumentsWasGivenBeforeAnyOptionalArguments() { int lastRequiredIndexedArgument = 0; int firstOptionalIndexedArgument = Integer.MAX_VALUE; for(int i = 0; i < indexedArguments.size(); i++) { Argument<?> indexedArgument = indexedArguments.get(i); if(indexedArgument.isRequired()) { lastRequiredIndexedArgument = i; } else if(firstOptionalIndexedArgument == Integer.MAX_VALUE) { firstOptionalIndexedArgument = i; } } checkArgument( lastRequiredIndexedArgument <= firstOptionalIndexedArgument, ProgrammaticErrors.REQUIRED_ARGUMENTS_BEFORE_OPTIONAL, firstOptionalIndexedArgument, lastRequiredIndexedArgument); } /** * Otherwise the error texts becomes ambiguous */ private void verifyUniqueMetasForRequiredAndIndexedArguments() { Set<String> metasForRequiredAndIndexedArguments = newHashSetWithExpectedSize(indexedArguments.size()); for(Argument<?> indexedArgument : filter(indexedArguments, IS_REQUIRED)) { String meta = indexedArgument.metaDescriptionInRightColumn(); boolean metaWasUnique = metasForRequiredAndIndexedArguments.add(meta); checkArgument(metaWasUnique, ProgrammaticErrors.UNIQUE_METAS, meta); } } /** * How would one know when the first argument considers itself satisfied? */ private void verifyThatOnlyOneArgumentIsOfVariableArity() { Collection<Argument<?>> indexedVariableArityArguments = filter(indexedArguments, IS_OF_VARIABLE_ARITY); checkArgument(indexedVariableArityArguments.size() <= 1, ProgrammaticErrors.SEVERAL_VARIABLE_ARITY_PARSERS, indexedVariableArityArguments); } @Nonnull ParsedArguments parse(final Iterable<String> actualArguments) throws ArgumentException { ArgumentIterator arguments = ArgumentIterator.forArguments(actualArguments, helpArguments); return parse(arguments, locale()); } @Nonnull ParsedArguments parse(ArgumentIterator arguments, Locale inLocale) throws ArgumentException { ParsedArguments holder = parseArguments(arguments, inLocale); Collection<Argument<?>> missingArguments = holder.requiredArgumentsLeft(); if(missingArguments.size() > 0) throw forMissingArguments(missingArguments).withUsage(usage(inLocale)); for(Argument<?> arg : holder.parsedArguments()) { holder.finalize(arg); limitArgument(arg, holder, inLocale); } if(!isCommandParser()) { arguments.executeLastCommand(); } return holder; } private ParsedArguments parseArguments(final ArgumentIterator iterator, Locale inLocale) throws ArgumentException { ParsedArguments holder = new ParsedArguments(allArguments()); iterator.setCurrentParser(this); while(iterator.hasNext()) { Argument<?> definition = null; try { iterator.setCurrentArgumentName(iterator.next()); definition = getDefinitionForCurrentArgument(iterator, holder); if(definition == null) { break; } parseArgument(iterator, holder, definition, inLocale); } catch(ArgumentException e) { e.withUsedArgumentName(iterator.getCurrentArgumentName()); if(definition != null) { e.withUsageReference(definition); } throw e.withUsage(usage(inLocale)); } } return holder; } private <T> void parseArgument(final ArgumentIterator arguments, final ParsedArguments parsedArguments, final Argument<T> definition, Locale inLocale) throws ArgumentException { if(parsedArguments.wasGiven(definition) && !definition.isAllowedToRepeat() && !definition.isPropertyMap()) throw forUnallowedRepetitionArgument(arguments.current()); InternalStringParser<T> parser = definition.parser(); T oldValue = parsedArguments.getValue(definition); T parsedValue = parser.parse(arguments, oldValue, definition, inLocale); parsedArguments.put(definition, parsedValue); } /** * @return a definition that defines how to handle the current argument * @throws UnexpectedArgumentException if no definition could be found * for the current argument */ @Nullable private Argument<?> getDefinitionForCurrentArgument(final ArgumentIterator iterator, final ParsedArguments holder) throws ArgumentException { if(iterator.allowsOptions()) { Argument<?> byName = lookupByName(iterator); if(byName != null) return byName; Argument<?> option = batchOfShortNamedArguments(iterator, holder); if(option != null) return option; } Argument<?> indexedArgument = indexedArgument(iterator, holder); if(indexedArgument != null) return indexedArgument; if(isCommandParser()) { // Rolling back here means that the parent parser/command will receive the argument // instead, maybe it can handle it iterator.previous(); return null; } guessAndSuggestIfCloseMatch(iterator, holder); // We're out of order, tell the user what we didn't like throw ArgumentExceptions.forUnexpectedArgument(iterator); } /** * Looks for {@link ArgumentBuilder#names(String...) named arguments} */ private Argument<?> lookupByName(ArgumentIterator arguments) { String currentArgument = arguments.current(); // Ordinary, named, arguments that directly matches the argument Argument<?> definition = namedArguments.get(currentArgument); if(definition != null) return definition; // Property Maps,Special separator, ignore case arguments Entry<String, Argument<?>> entry = specialArguments.get(currentArgument); if(entry != null) { // Remove "-D" from "-Dkey=value" String valueAfterSeparator = currentArgument.substring(entry.getKey().length()); arguments.setNextArgumentTo(valueAfterSeparator); return entry.getValue(); } definition = arguments.helpArgument(currentArgument); return definition; } /** * Batch of short-named optional arguments * For instance, "-fs" was used instead of "-f -s" */ private Argument<?> batchOfShortNamedArguments(ArgumentIterator arguments, ParsedArguments holder) throws ArgumentException { String currentArgument = arguments.current(); if(startsWithAndHasMore(currentArgument, "-")) { List<Character> givenCharacters = Lists.charactersOf(currentArgument.substring(1)); Set<Argument<?>> foundOptions = newLinkedHashSetWithExpectedSize(givenCharacters.size()); Argument<?> lastOption = null; for(Character optionName : givenCharacters) { lastOption = namedArguments.get("-" + optionName); if(lastOption == null || lastOption.parser().parameterArity() != NO_ARGUMENTS || !foundOptions.add(lastOption)) { // Abort as soon as an unexpected character is discovered break; } } // Only accept the argument if all characters could be matched and no duplicate // characters were found if(foundOptions.size() == givenCharacters.size()) { // The last option is handled with the return foundOptions.remove(lastOption); // A little ugly that this get method has side-effects but the alternative is to // return a list of arguments to parse and as this should be a rare case it's not // worth it, the result is the same at least for(Argument<?> option : foundOptions) { parseArgument(arguments, holder, option, null); } return lastOption; } } return null; } /** * Looks for {@link ArgumentBuilder#names(String...) indexed arguments} */ private Argument<?> indexedArgument(ArgumentIterator arguments, ParsedArguments holder) { if(holder.indexedArgumentsParsed() < indexedArguments.size()) { Argument<?> definition = indexedArguments.get(holder.indexedArgumentsParsed()); arguments.previous(); // This helps the error messages explain which of the indexed arguments that failed if(isCommandParser()) { arguments.setCurrentArgumentName(arguments.usedCommandName()); } else { arguments.setCurrentArgumentName(definition.metaDescriptionInRightColumn()); } return definition; } return null; } /** * Suggests probable, valid, alternatives for a faulty argument, based on the * {@link StringsUtil#levenshteinDistance(String, String)} */ private void guessAndSuggestIfCloseMatch(ArgumentIterator arguments, ParsedArguments holder) throws ArgumentException { Set<String> availableArguments = Sets.union(holder.nonParsedArguments(), arguments.nonParsedArguments()); if(!availableArguments.isEmpty()) { List<String> suggestions = StringsUtil.closestMatches(arguments.current(), availableArguments, ONLY_REALLY_CLOSE_MATCHES); if(!suggestions.isEmpty()) throw withMessage(format(UserErrors.SUGGESTION, arguments.current(), NEW_LINE_AND_TAB.join(suggestions))); } } private static final int ONLY_REALLY_CLOSE_MATCHES = 4; private static final Joiner NEW_LINE_AND_TAB = Joiner.on(NEWLINE + TAB); private <T> void limitArgument(@Nonnull Argument<T> arg, ParsedArguments holder, Locale inLocale) throws ArgumentException { try { arg.checkLimit(holder.getValue(arg)); } catch(IllegalArgumentException e) { throw wrapException(e).withUsageReference(arg).withUsedArgumentName(arg.toString()).withUsage(usage(inLocale)); } catch(ArgumentException e) { throw e.withUsageReference(arg).withUsage(usage(inLocale)); } } /** * Returns <code>true</code> if this is a parser for a specific {@link Command} */ boolean isCommandParser() { return isCommandParser; } Set<Argument<?>> allArguments() { return allArguments; } ProgramInformation programInformation() { return programInformation; } Locale locale() { return locale; } @CheckReturnValue @Nonnull Usage usage(Locale inLocale) { return new Usage(allArguments(), inLocale, programInformation(), isCommandParser()); } @CheckReturnValue @Nonnull ArgumentException helpFor(ArgumentIterator arguments, Locale inLocale) throws ArgumentException { ArgumentException e = withMessage("Help requested with " + arguments.current()); Usage usage = null; if(arguments.hasNext()) { arguments.next(); Argument<?> argument = lookupByName(arguments); if(argument == null) throw withMessage(format(UserErrors.UNKNOWN_ARGUMENT, arguments.current())); usage = new Usage(Arrays.<Argument<?>>asList(argument), inLocale, programInformation(), isCommandParser()); if(isCommandParser()) { String withCommandReference = ". Usage for " + argument + " (argument to " + arguments.usedCommandName() + "):"; e.withUsageReference(withCommandReference); } else { e.withUsageReference(argument); } } else { usage = usage(inLocale); if(isCommandParser()) { e.withUsageReference(". See usage for " + arguments.usedCommandName() + " below:"); } else { e.withUsageReference(". See usage below:"); } } return e.withUsage(usage); } @Override public String toString() { return usage(locale()).toString(); } /** * Wraps a list of given arguments and remembers * which argument that is currently being parsed. Plays a key role in making * {@link CommandLineParserInstance} {@link ThreadSafe} as it holds the current state of a parse * invocation. */ @NotThreadSafe static final class ArgumentIterator extends UnmodifiableIterator<String> { private final List<String> arguments; /** * Corresponds to one of the {@link Argument#names()} that has been given from the command * line. This is updated as soon as the parsing of a new argument begins. * For indexed arguments this will be the meta description instead. */ private String currentArgumentName; private int currentArgumentIndex; private boolean endOfOptionsReceived; private int indexOfLastCommand = -1; private Command lastCommandParsed; private ParsedArguments argumentsToLastCommand; /** * In case of {@link Command}s this may be the parser for a specific {@link Command} or just * simply the main parser */ private CommandLineParserInstance currentParser; private final Map<String, Argument<?>> helpArguments; /** * @param actualArguments a list of arguments, will be modified */ private ArgumentIterator(Iterable<String> actualArguments, Map<String, Argument<?>> helpArguments) { this.arguments = checkNulls(actualArguments, "Argument strings may not be null"); this.helpArguments = helpArguments; } Argument<?> helpArgument(String currentArgument) { return helpArguments.get(currentArgument); } /** * Returns <code>true</code> if {@link UsageTexts#END_OF_OPTIONS} hasn't been received yet. */ boolean allowsOptions() { return !endOfOptionsReceived; } void setCurrentParser(CommandLineParserInstance instance) { currentParser = instance; } void rememberAsCommand() { executeLastCommand(); // The command has moved the index by 1 therefore the -1 to get the index of the // commandName indexOfLastCommand = currentArgumentIndex - 1; } void rememberInvocationOfCommand(Command command, ParsedArguments argumentsToCommand) { executeLastCommand(); lastCommandParsed = command; this.argumentsToLastCommand = argumentsToCommand; } void executeLastCommand() { if(lastCommandParsed != null) { lastCommandParsed.execute(argumentsToLastCommand); lastCommandParsed = null; } } /** * Returns any non-parsed arguments to the last command that was executed */ Set<String> nonParsedArguments() { if(lastCommandParsed != null) return argumentsToLastCommand.nonParsedArguments(); return emptySet(); } /** * For indexed arguments in commands the used command name is returned so that when * multiple commands (or multiple command names) are used it's clear which command the * offending argument is part of */ String usedCommandName() { return arguments.get(indexOfLastCommand); } static ArgumentIterator forArguments(Iterable<String> arguments, Map<String, Argument<?>> helpArguments) { return new ArgumentIterator(arguments, helpArguments); } static ArgumentIterator forArguments(Iterable<String> arguments) { return new ArgumentIterator(arguments, Collections.<String, Argument<?>>emptyMap()); } /** * Returns the string that was given by the previous {@link #next()} invocation. */ String current() { return arguments.get(currentArgumentIndex - 1); } @Override public boolean hasNext() { return currentArgumentIndex < arguments.size(); } @Override public String next() { String nextArgument = arguments.get(currentArgumentIndex++); nextArgument = skipAheadIfEndOfOptions(nextArgument); nextArgument = readArgumentsFromFile(nextArgument); return nextArgument; } /** * Skips {@link UsageTexts#END_OF_OPTIONS} if the parser hasn't received it yet. * This is to allow the string {@link UsageTexts#END_OF_OPTIONS} as an indexed argument * itself. */ private String skipAheadIfEndOfOptions(String nextArgument) { if(!endOfOptionsReceived && nextArgument.equals(UsageTexts.END_OF_OPTIONS)) { endOfOptionsReceived = true; return next(); } return nextArgument; } /** * Reads arguments from files if the argument starts with a * {@link UsageTexts#FILE_REFERENCE_PREFIX}. */ private String readArgumentsFromFile(String nextArgument) { // TODO(jontejj): add possibility to disable this feature? It has some security // implications as the caller can input any files and if this parser was exposed from a // server... if(nextArgument.startsWith(UsageTexts.FILE_REFERENCE_PREFIX)) { String filename = nextArgument.substring(1); File fileWithArguments = new File(filename); if(fileWithArguments.exists()) { try { appendArgumentsAtCurrentPosition(Files.readLines(fileWithArguments, Charsets.UTF_8)); } catch(IOException errorWhileReadingFile) { throw withMessage("Failed while reading arguments from: " + filename, errorWhileReadingFile); } // Recursive call adds support for file references from within the file itself return next(); } } return nextArgument; } private void appendArgumentsAtCurrentPosition(List<String> argumentsToAppend) { arguments.addAll(currentArgumentIndex, argumentsToAppend); } @Override public String toString() { return arguments.subList(currentArgumentIndex, arguments.size()).toString(); } /** * The opposite of {@link #next()}. In short it makes this iterator return what * {@link #next()} returned last time once again. * * @return the {@link #current()} argument */ String previous() { return arguments.get(--currentArgumentIndex); } int nrOfRemainingArguments() { return arguments.size() - currentArgumentIndex; } void setNextArgumentTo(String newNextArgumentString) { arguments.set(--currentArgumentIndex, newNextArgumentString); } boolean hasPrevious() { return currentArgumentIndex > 0; } void setCurrentArgumentName(String argumentName) { currentArgumentName = argumentName; } String getCurrentArgumentName() { return currentArgumentName; } CommandLineParserInstance currentParser() { return currentParser; } } private static final class NamedArguments { private final Map<String, Argument<?>> namedArguments; NamedArguments(int expectedSize) { namedArguments = newHashMapWithExpectedSize(expectedSize); } Argument<?> put(String name, Argument<?> definition) { if(definition.isIgnoringCase()) return namedArguments.put(name.toLowerCase(Locale.ENGLISH), definition); return namedArguments.put(name, definition); } Argument<?> get(String name) { Argument<?> definition = namedArguments.get(name); if(definition != null) return definition; String lowerCase = name.toLowerCase(Locale.ENGLISH); definition = namedArguments.get(lowerCase); if(definition != null && definition.isIgnoringCase()) return definition; return null; } } private static final class SpecialArguments { private final CharacterTrie<Argument<?>> specialArguments; SpecialArguments() { specialArguments = CharacterTrie.newTrie(); } Argument<?> put(String name, Argument<?> definition) { if(definition.isIgnoringCase()) return specialArguments.put(name.toLowerCase(Locale.ENGLISH), definition); return specialArguments.put(name, definition); } Entry<String, Argument<?>> get(String name) { Entry<String, Argument<?>> entry = specialArguments.findLongestPrefix(name); if(entry != null) return entry; String lowerCase = name.toLowerCase(Locale.ENGLISH); entry = specialArguments.findLongestPrefix(lowerCase); if(entry != null && entry.getValue().isIgnoringCase()) return entry; return null; } } }