// =================================================================================================
// Copyright 2011 Twitter, Inc.
// -------------------------------------------------------------------------------------------------
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this work except in compliance with the License.
// You may obtain a copy of the License in the LICENSE file, or 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 com.twitter.common.args;
import java.io.IOException;
import java.io.PrintStream;
import java.lang.reflect.Field;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.logging.Logger;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.annotation.Nullable;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Function;
import com.google.common.base.Joiner;
import com.google.common.base.Optional;
import com.google.common.base.Preconditions;
import com.google.common.base.Predicate;
import com.google.common.base.Predicates;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableList.Builder;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableMultimap;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Iterables;
import com.google.common.collect.Lists;
import com.google.common.collect.Maps;
import com.google.common.collect.Ordering;
import com.google.common.collect.Sets;
import com.twitter.common.args.apt.Configuration;
import com.twitter.common.collections.Pair;
import static com.google.common.base.Preconditions.checkArgument;
/**
* Argument scanning, parsing, and validating system. This class is designed recursively scan a
* package for declared arguments, parse the values based on the declared type, and validate against
* any constraints that the arugment is decorated with.
*
* The supported argument formats are:
* -arg_name=arg_value
* -arg_name arg_value
* Where {@code arg_value} may be single or double-quoted if desired or necessary to prevent
* splitting by the terminal application.
*
* A special format for boolean arguments is also supported. The following syntaxes all set the
* {@code bool_arg} to {@code true}:
* -bool_arg
* -bool_arg=true
* -no_bool_arg=false (double negation)
*
* Likewise, the following would set {@code bool_arg} to {@code false}:
* -no_bool_arg
* -bool_arg=false
* -no_bool_arg=true (negation)
*
* As with the general argument format, spaces may be used in place of equals for boolean argument
* assignment.
*
* TODO(William Farner): Make default verifier and parser classes package-private and in this
* package.
*
* @author William Farner
*/
public final class ArgScanner {
private static final Function<OptionInfo, String> GET_OPTIONINFO_NAME =
new Function<OptionInfo, String>() {
@Override public String apply(OptionInfo optionInfo) {
return optionInfo.getName();
}
};
public static final Ordering<OptionInfo> ORDER_BY_NAME =
Ordering.natural().onResultOf(GET_OPTIONINFO_NAME);
private static final Function<String, String> ARG_NAME_TO_FLAG = new Function<String, String>() {
@Override public String apply(String argName) {
return "-" + argName;
}
};
private static final Predicate<OptionInfo> IS_BOOLEAN =
new Predicate<OptionInfo>() {
@Override public boolean apply(OptionInfo optionInfo) {
return optionInfo.isBoolean();
}
};
// Regular expression to identify a possible dangling assignment.
// A dangling assignment occurs in two cases:
// - The command line used spaces between arg names and values, causing the name and value to
// end up in different command line arg array elements.
// - The command line is using the short form for a boolean argument,
// such as -use_feature, or -no_use_feature.
private static final String DANGLING_ASSIGNMENT_RE =
String.format("^-%s", OptionInfo.ARG_NAME_RE);
private static final Pattern DANGLING_ASSIGNMENT_PATTERN =
Pattern.compile(DANGLING_ASSIGNMENT_RE);
// Pattern to identify a full assignment, which would be disassociated from a preceding dangling
// assignment.
private static final Pattern ASSIGNMENT_PATTERN =
Pattern.compile(String.format("%s=.+", DANGLING_ASSIGNMENT_RE));
/**
* Extracts the name from an @OptionInfo.
*/
private static final Function<OptionInfo, String> GET_OPTIONINFO_NEGATED_NAME =
new Function<OptionInfo, String>() {
@Override public String apply(OptionInfo optionInfo) {
return optionInfo.getNegatedName();
}
};
/**
* Gets the canonical name for an @Arg, based on the class containing the field it annotates.
*/
private static final Function<OptionInfo, String> GET_CANONICAL_ARG_NAME =
new Function<OptionInfo, String>() {
@Override public String apply(OptionInfo optionInfo) {
return optionInfo.getCanonicalName();
}
};
/**
* Gets the canonical negated name for an @Arg.
*/
private static final Function<OptionInfo, String> GET_CANONICAL_NEGATED_ARG_NAME =
new Function<OptionInfo, String>() {
@Override public String apply(OptionInfo optionInfo) {
return optionInfo.getCanonicalNegatedName();
}
};
private static final Logger LOG = Logger.getLogger(ArgScanner.class.getName());
// Pattern for the required argument format.
private static final Pattern ARG_PATTERN =
Pattern.compile(String.format("-(%s)(?:(?:=| +)(.*))?", OptionInfo.ARG_NAME_RE));
private static final Pattern QUOTE_PATTERN = Pattern.compile("(['\"])([^\\\1]*)\\1");
private final PrintStream out;
/**
* Equivalent to calling {@link #ArgScanner(PrintStream)} passing {@link System#out}.
*/
public ArgScanner() {
this(System.out);
}
/**
* Creates a new ArgScanner that prints help on arg parse failure or when help is requested to
* {@code out} or else prints applied argument information to {@code out} when parsing is
* successful.
*
* @param out An output stream to write help and parsed argument info to.
*/
public ArgScanner(PrintStream out) {
this.out = Preconditions.checkNotNull(out);
}
/**
* Applies the provided argument values to all {@literal @CmdLine} {@code Arg} fields discovered
* on the classpath.
*
* @param args Argument values to map, parse, validate, and apply.
* @return {@code true} if the given {@code args} were successfully applied to their corresponding
* {@link Arg} fields.
* @throws ArgScanException if there was a problem loading {@literal @CmdLine} argument
* definitions
* @throws IllegalArgumentException If the arguments provided are invalid based on the declared
* arguments found.
*/
public boolean parse(Iterable<String> args) {
return parse(ArgFilters.SELECT_ALL, args);
}
/**
* Applies the provided argument values to any {@literal @CmdLine} or {@literal @Positional}
* {@code Arg} fields discovered on the classpath and accepted by the given {@code filter}.
*
* @param filter A predicate that selects or rejects scanned {@literal @CmdLine} fields for
* argument application.
* @param args Argument values to map, parse, validate, and apply.
* @return {@code true} if the given {@code args} were successfully applied to their corresponding
* {@link Arg} fields.
* @throws ArgScanException if there was a problem loading {@literal @CmdLine} argument
* definitions
* @throws IllegalArgumentException If the arguments provided are invalid based on the declared
* arguments found.
*/
public boolean parse(Predicate<Field> filter, Iterable<String> args) {
Configuration configuration = load();
Args.ArgumentInfo argumentInfo = Args.fromConfiguration(configuration, filter);
return parse(argumentInfo, configuration, args);
}
/**
* Parse command line arguments given a {@link Args.ArgumentInfo}
*
* @param argumentInfo A description of any optional and positional arguments
* @param args A sequence of strings from the command-line
* @return {@code true} if the given {@code args} were successfully applied to their corresponding
* {@link Arg} fields.
* @throws ArgScanException if there was a problem loading {@literal @CmdLine} argument
* definitions
* @throws IllegalArgumentException If the arguments provided are invalid based on the declared
* arguments found.
*/
public boolean parse(Args.ArgumentInfo argumentInfo, Iterable<String> args) {
Configuration configuration = load();
return parse(argumentInfo, configuration, args);
}
private boolean parse(Args.ArgumentInfo argumentInfo, Configuration configuration,
Iterable<String> args) {
ParserOracle parserOracle = Parsers.fromConfiguration(configuration);
Verifiers verifiers = Verifiers.fromConfiguration(configuration);
Pair<ImmutableMap<String, String>, List<String>> results = mapArguments(args);
return process(parserOracle, verifiers, argumentInfo, results.getFirst(), results.getSecond());
}
private Configuration load() {
try {
return Configuration.load();
} catch (IOException e) {
throw new ArgScanException(e);
}
}
@VisibleForTesting static List<String> joinKeysToValues(Iterable<String> args) {
List<String> joinedArgs = Lists.newArrayList();
String unmappedKey = null;
for (String arg : args) {
if (unmappedKey == null) {
if (DANGLING_ASSIGNMENT_PATTERN.matcher(arg).matches()) {
// Beginning of a possible dangling assignment.
unmappedKey = arg;
} else {
joinedArgs.add(arg);
}
} else {
if (ASSIGNMENT_PATTERN.matcher(arg).matches()) {
// Full assignment, disassociate from dangling assignment.
joinedArgs.add(unmappedKey);
joinedArgs.add(arg);
unmappedKey = null;
} else if (DANGLING_ASSIGNMENT_PATTERN.matcher(arg).find()) {
// Another dangling assignment, this could be two sequential boolean args.
joinedArgs.add(unmappedKey);
unmappedKey = arg;
} else {
// Join the dangling key with its value.
joinedArgs.add(unmappedKey + "=" + arg);
unmappedKey = null;
}
}
}
if (unmappedKey != null) {
joinedArgs.add(unmappedKey);
}
return joinedArgs;
}
private static String stripQuotes(String str) {
Matcher matcher = QUOTE_PATTERN.matcher(str);
return matcher.matches() ? matcher.group(2) : str;
}
/**
* Scans through args, mapping keys to values even if the arg values are 'dangling' and reside
* in different array entries than the respective keys.
*
* @param args Arguments to build into a map.
* @return A map from argument key (arg name) to value paired with a list of any leftover
* positional arguments.
*/
private static Pair<ImmutableMap<String, String>, List<String>> mapArguments(
Iterable<String> args) {
ImmutableMap.Builder<String, String> argMap = ImmutableMap.builder();
List<String> positionalArgs = Lists.newArrayList();
for (String arg : joinKeysToValues(args)) {
if (!arg.startsWith("-")) {
positionalArgs.add(arg);
} else {
Matcher matcher = ARG_PATTERN.matcher(arg);
checkArgument(matcher.matches(),
String.format("Argument '%s' does not match required format -arg_name=arg_value", arg));
String rawValue = matcher.group(2);
// An empty string denotes that the argument was passed with no value.
rawValue = rawValue == null ? "" : stripQuotes(rawValue);
argMap.put(matcher.group(1), rawValue);
}
}
return Pair.of(argMap.build(), positionalArgs);
}
private static <T> Set<T> dropCollisions(Iterable<T> input) {
Set<T> copy = Sets.newHashSet();
Set<T> collisions = Sets.newHashSet();
for (T entry : input) {
if (!copy.add(entry)) {
collisions.add(entry);
}
}
copy.removeAll(collisions);
return copy;
}
/**
* Applies argument values to fields based on their annotations.
*
* @param parserOracle ParserOracle available to parse raw args with.
* @param verifiers Verifiers available to verify argument constraints with.
* @param argumentInfo Fields to apply argument values to.
* @param args Unparsed argument values.
* @param positionalArgs The unparsed positional arguments.
* @return {@code true} if the given {@code args} were successfully applied to their
* corresponding {@link com.twitter.common.args.Arg} fields.
*/
private boolean process(final ParserOracle parserOracle,
Verifiers verifiers,
Args.ArgumentInfo argumentInfo,
Map<String, String> args,
List<String> positionalArgs) {
if (!Sets.intersection(args.keySet(), ArgumentInfo.HELP_ARGS).isEmpty()) {
printHelp(verifiers, argumentInfo);
return false;
}
Optional<? extends PositionalInfo<?>> positionalInfoOptional = argumentInfo.getPositionalInfo();
checkArgument(positionalInfoOptional.isPresent() || positionalArgs.isEmpty(),
"Positional arguments have been supplied but there is no Arg annotated to received them.");
Iterable<? extends OptionInfo<?>> optionInfos = argumentInfo.getOptionInfos();
final Set<String> argsFailedToParse = Sets.newHashSet();
final Set<String> argsConstraintsFailed = Sets.newHashSet();
Iterable<String> argShortNames = Iterables.transform(optionInfos, GET_OPTIONINFO_NAME);
Set<String> argShortNamesNoCollisions = dropCollisions(argShortNames);
Set<String> collisionsDropped = Sets.difference(ImmutableSet.copyOf(argShortNames),
argShortNamesNoCollisions);
if (!collisionsDropped.isEmpty()) {
LOG.warning("Found argument name collisions, args must be referenced by canonical names: "
+ collisionsDropped);
}
final Map<String, OptionInfo> argsByName =
ImmutableMap.<String, OptionInfo>builder()
// Map by short arg name -> arg def.
.putAll(Maps.uniqueIndex(Iterables.filter(optionInfos,
Predicates.compose(Predicates.in(argShortNamesNoCollisions), GET_OPTIONINFO_NAME)),
GET_OPTIONINFO_NAME))
// Map by canonical arg name -> arg def.
.putAll(Maps.uniqueIndex(optionInfos, GET_CANONICAL_ARG_NAME))
// Map by negated short arg name (for booleans)
.putAll(Maps.uniqueIndex(Iterables.filter(optionInfos, IS_BOOLEAN),
GET_OPTIONINFO_NEGATED_NAME))
// Map by negated canonical arg name (for booleans)
.putAll(Maps.uniqueIndex(Iterables.filter(optionInfos, IS_BOOLEAN),
GET_CANONICAL_NEGATED_ARG_NAME))
.build();
// TODO(William Farner): Make sure to disallow duplicate arg specification by short and
// canonical names.
// TODO(William Farner): Support non-atomic argument constraints. @OnlyIfSet, @OnlyIfNotSet,
// @ExclusiveOf to define inter-argument constraints.
Set<String> recognizedArgs = Sets.intersection(argsByName.keySet(), args.keySet());
for (String argName : recognizedArgs) {
String argValue = args.get(argName);
OptionInfo optionInfo = argsByName.get(argName);
try {
optionInfo.load(parserOracle, argName, argValue);
} catch (IllegalArgumentException e) {
argsFailedToParse.add(argName + " - " + e.getMessage());
}
}
if (positionalInfoOptional.isPresent()) {
PositionalInfo<?> positionalInfo = positionalInfoOptional.get();
positionalInfo.load(parserOracle, positionalArgs);
}
Set<String> commandLineArgumentInfos = Sets.newTreeSet();
Iterable<? extends ArgumentInfo<?>> allArguments = argumentInfo.getOptionInfos();
if (positionalInfoOptional.isPresent()) {
PositionalInfo<?> positionalInfo = positionalInfoOptional.get();
allArguments = Iterables.concat(optionInfos, ImmutableList.of(positionalInfo));
}
for (ArgumentInfo<?> anArgumentInfo : allArguments) {
Arg<?> arg = anArgumentInfo.getArg();
commandLineArgumentInfos.add(String.format("%s (%s): %s",
anArgumentInfo.getName(), anArgumentInfo.getCanonicalName(),
arg.uncheckedGet()));
try {
anArgumentInfo.verify(verifiers);
} catch (IllegalArgumentException e) {
argsConstraintsFailed.add(anArgumentInfo.getName() + " - " + e.getMessage());
}
}
ImmutableMultimap<String, String> warningMessages =
ImmutableMultimap.<String, String>builder()
.putAll("Unrecognized arguments", Sets.difference(args.keySet(), argsByName.keySet()))
.putAll("Failed to parse", argsFailedToParse)
.putAll("Value did not meet constraints", argsConstraintsFailed)
.build();
if (!warningMessages.isEmpty()) {
printHelp(verifiers, argumentInfo);
StringBuilder sb = new StringBuilder();
for (Map.Entry<String, Collection<String>> warnings : warningMessages.asMap().entrySet()) {
sb.append(warnings.getKey()).append(":\n\t").append(Joiner.on("\n\t")
.join(warnings.getValue())).append("\n");
}
throw new IllegalArgumentException(sb.toString());
}
LOG.info("-------------------------------------------------------------------------");
LOG.info("Command line argument values");
for (String commandLineArgumentInfo : commandLineArgumentInfos) {
LOG.info(commandLineArgumentInfo);
}
LOG.info("-------------------------------------------------------------------------");
return true;
}
private void printHelp(Verifiers verifiers, Args.ArgumentInfo argumentInfo) {
ImmutableList.Builder<String> requiredHelps = ImmutableList.builder();
ImmutableList.Builder<String> optionalHelps = ImmutableList.builder();
for (OptionInfo<?> optionInfo
: ORDER_BY_NAME.immutableSortedCopy(argumentInfo.getOptionInfos())) {
Arg<?> arg = optionInfo.getArg();
Object defaultValue = arg.uncheckedGet();
ImmutableList<String> constraints = collectConstraints(verifiers, optionInfo);
String help = formatHelp(optionInfo, constraints, defaultValue);
if (!arg.hasDefault()) {
requiredHelps.add(help);
} else {
optionalHelps.add(help);
}
}
infoLog("-------------------------------------------------------------------------");
infoLog(String.format("%s to print this help message",
Joiner.on(" or ").join(Iterables.transform(ArgumentInfo.HELP_ARGS, ARG_NAME_TO_FLAG))));
Optional<? extends PositionalInfo<?>> positionalInfoOptional = argumentInfo.getPositionalInfo();
if (positionalInfoOptional.isPresent()) {
infoLog("\nPositional args:");
PositionalInfo<?> positionalInfo = positionalInfoOptional.get();
Arg<?> arg = positionalInfo.getArg();
Object defaultValue = arg.uncheckedGet();
ImmutableList<String> constraints = collectConstraints(verifiers, positionalInfo);
infoLog(String.format("%s%s\n\t%s\n\t(%s)",
defaultValue != null ? "default " + defaultValue : "",
Iterables.isEmpty(constraints)
? ""
: " [" + Joiner.on(", ").join(constraints) + "]",
positionalInfo.getHelp(),
positionalInfo.getCanonicalName()));
}
ImmutableList<String> required = requiredHelps.build();
if (!required.isEmpty()) {
infoLog("\nRequired flags:"); // yes - this should actually throw!
infoLog(Joiner.on('\n').join(required));
}
ImmutableList<String> optional = optionalHelps.build();
if (!optional.isEmpty()) {
infoLog("\nOptional flags:");
infoLog(Joiner.on('\n').join(optional));
}
infoLog("-------------------------------------------------------------------------");
}
private ImmutableList<String> collectConstraints(Verifiers verifierOracle,
ArgumentInfo<?> argumentInfo) {
Builder<String> builder = ImmutableList.builder();
argumentInfo.collectConstraints(verifierOracle, builder);
return builder.build();
}
private String formatHelp(ArgumentInfo<?> argumentInfo, Iterable<String> constraints,
@Nullable Object defaultValue) {
return String.format("-%s%s%s\n\t%s\n\t(%s)",
argumentInfo.getName(),
defaultValue != null ? "=" + defaultValue : "",
Iterables.isEmpty(constraints)
? ""
: " [" + Joiner.on(", ").join(constraints) + "]",
argumentInfo.getHelp(),
argumentInfo.getCanonicalName());
}
private void infoLog(String msg) {
out.println(msg);
}
/**
* Indicates a problem scanning {@literal @CmdLine} arg definitions.
*/
public static class ArgScanException extends RuntimeException {
public ArgScanException(Throwable cause) {
super(cause);
}
}
}