package org.ovirt.engine.core.uutils.cli.parser;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.Reader;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.Set;
import java.util.TreeSet;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.apache.commons.lang.StringUtils;
/**
* This class parses properties file where user declare structure of command line arguments.
* Every argument can declare following attributes:
* <ul>
* <li>name - Name of the argument, <b>Must be declared by developer</b></li>
* <li>help - Help to be printed to argument, when user request help to be printed (default: empty string)</li>
* <li>mandatory - true/false declares if argument have to be specified or not (default: false)</li>
* <li>type - one of:
* <ul>
* <li>required_argument - argument requires value</li>
* <li>optional_argument - argument could have value</li>
* <li>no_argument - argument doesn't have value (default)</li>
* </ul>
* </li>
* <li>convert - Name of the class that arguments value should be converted to (default: java.lang.String)</li>
* <li>matcher - Regular expression which value of argument need to fulfill. (default: .*)</li>
* <li>metavar - Used in only usage print. Give clue to user what expect as a value. (default: STRING)</li>
* <li>multivalue - true/false declares if value is list of values or single value (default: false)</li>
* <li>default - If argument name is not found in list of user arguments.
* It will add this argument with value of this attribute. (default: null)</li>
* <li>value - Value to be set to argument if argument value is not required (default: null)</li>
* </ul>
*
* <p>
* To be able to ensure program can handle more usages for specific actions we need to specify prefixes for arguments.
* That means that final declaration of argument in properties file will look like:
*
* <p><i><prefix>.arg.<argument_name>.<attribute> = value</i></p>
*
* ArgumentsParser.parse() will parse only arguments with specified prefix. If parser hit value which don't start with "--",
* it will exit parsing, validate current arguments. In attribute {@code argMap} you can find parsed arguments.
* In attribute @{code errors} you can find all errors which were found during parsing. The rest of arguments, which parser
* didn't parse stay in list, which is paramater of parse() method.
* </p>
*
* <p>
* If there appears any error while parsing arguments(like, required value missing, mandatory not specified, etc).
* All these errors will be stored in {@code errors} attribute.
* </p>
*
* <p>
* Properties file also specify three basic variables that stores usage print, Variables names are as follows:
* <ul>
* <li><i>help.usage</i> - Declares usage of program/module (default: empty)</li>
* <li><i>help.header</i> - Declares header of program/module, usually used for description of program/module (default: empty)</li>
* <li><i>help.footer</i> - Declares footer of program/module, usually used for additional info for consumer of program/module (default: empty)</li>
* </ul>
* </p>
*
* The usage is printed in following structure:
* <pre>
* """
* $help.usage
* $help.header
*
* Options:
* --<prefix>arg.argument1.name
* <prefix>arg.argument1.help
*
* --<prefix>arg.argument2.name
* <prefix>arg.argument2.help
*
* $help.footer
* """
* </pre>
* Example:<br/>
* We have a program which support two actions - [add, remove]. Both actions accept different arguments and program too.
*
* <pre>
* ./out [--file] add --message=X [--index]
* ./out [--file] remove [--index]
* </pre>
* The properties file of this program can look like:
* <pre>
* module.arg.file.name = file
* module.arg.file.help = File where messages are stored
* module.arg.file.default = /tmp/X
* add.arg.message.name = message
* add.arg.message.help = Message to be stored
* add.arg.message.mandatory = true
* add.arg.message.type = required_argument
* add.arg.index.name = index
* add.arg.index.help = Index where message should be inserted
* add.arg.index.value = 0
* add.arg.index.default = 0
* add.arg.index.type = optional_argument
* remove.arg.index.name = index
* remove.arg.index.help = Index where message should be inserted
* remove.arg.index.value = 0
* remove.arg.index.default = 0
* remove.arg.index.type = optional_argument
*
* ArgumentsParser parser = new ArgumentsParser(inputStream)
* Map<String, Object> moduleArgMap = parser.parse(args)
* print moduleArgMap.get("file")
*
* Listp<Object> others = moduleArgMap.get(PARAMETERS_KEY_OTHERS)
* print others.remove(0) // action/remove
*
* Mapp<String, Objectp> actionArgMap = parser.parse(others)
* print actionArgMap.get("index")
* </pre>
*/
public class ArgumentsParser {
/**
* Prefix which every argument should use to be considered argument.
*/
private static final String LONG_PREFIX = "--";
/**
* Stores default values of every argument, if user don't override it default from this file will be used.
*/
private static Properties defaultProperties;
static {
try (InputStream is = ArgumentsParser.class.getResourceAsStream("defaults.properties")) {
defaultProperties = loadProperties(is);
} catch (IOException e) {
defaultProperties = null;
}
}
/**
* Stores user defined arguments in properties file.
*/
private Properties properties;
/**
* The prefix which should be used for parsing of properties file.
*/
private String prefix;
/**
* Map which stores parsed converted arguments.
*/
private Map<String, Argument> arguments = new HashMap<>();
/**
* Set of mandatory arguments to check if user specified all of them.
*/
private Set<String> mandatory = new HashSet<>();
/**
* Map of correctly parsed and converted arguments
*/
private Map<String, Object> parsedArgs;
/**
* List of errors which was found during pasring
*/
private List<Throwable> errors;
/**
* Map of predefined substitutions which should be replaced in usage.
*/
private Map<String, String> substitutions = new HashMap<>();
/**
* Inititilize ArgumentsParser attributes. Parser properties file and create argument map of it.
*
* @param properties properties file with defined arguments
* @param prefix only arguments with this prefix will be parsed
*/
public ArgumentsParser(Properties properties, String prefix) {
this.properties = properties;
this.prefix = prefix;
parseProperties();
}
/**
* Inititilize ArgumentsParser attributes. Parse properties file and create argument map of it.
*
* @param resource Input resource with defined arguments
* @param prefix only arguments with this prefix will be parsed
*/
public ArgumentsParser(InputStream resource, String prefix) {
this(loadProperties(resource), prefix);
}
/**
* Return list of erros
*
* @return list of erros
*/
public List<Throwable> getErrors() {
return Collections.unmodifiableList(errors);
}
/**
* Return map of validated and parsed arguments
*
* @return Map of validated and parsed arguments
*/
public Map<String, Object> getParsedArgs() {
return Collections.unmodifiableMap(parsedArgs);
}
/**
* Parse list of arguments based on definition declared in {@link #properties} file with {@link #prefix}.
* Please note that param {@code args} will be modified and after method finished it will contain rest
* of arguments(all arguments which follow argument without '--' prefix).
*
* @param args list of command line arguments
* @return true if parsing has no errors, false otherwise
*/
public boolean parse(List<String> args) {
parsedArgs = new HashMap<>();
errors = new ArrayList<>();
while(!args.isEmpty()) {
String arg = args.get(0);
if(!arg.startsWith(LONG_PREFIX)) {
break;
}
arg = args.remove(0);
if(arg.equals(LONG_PREFIX)) {
break;
}
String[] argVal = parseArgument(arg.substring(2));
String key = argVal[0];
String value = argVal[1];
Argument argument = arguments.get(key);
if(argument == null) {
errors.add(
new IllegalArgumentException(String.format("Invalid argument '%1$s'", arg))
);
} else {
if (
value == null &&
(
argument.getType() == Argument.Type.OPTIONAL_ARGUMENT ||
argument.getType() == Argument.Type.REQUIRED_ARGUMENT
)
) {
if(args.size() > 0) {
value = args.get(0);
if (value.startsWith(LONG_PREFIX)) {
value = null;
} else {
args.remove(0);
}
}
}
if (argument.getType() == Argument.Type.REQUIRED_ARGUMENT && value == null) {
errors.add(
new IllegalArgumentException(
String.format("Value is required, but missing for argument '%1$s'", key)
)
);
}
if (value == null) {
value = argument.getValue();
}
Object convertedValue = null;
if (value != null) {
Matcher m = argument.getMatcher().matcher(value);
if (!m.matches()) {
errors.add(
new IllegalArgumentException(
String.format(
"Pattern for argument '%1$s' does not match, pattern is '%2$s', value is '%3$s'", key, m.pattern(), value
)
)
);
}
convertedValue = StringValueConverter.getObjectValueByString(argument.getValueType(), value);
}
putValue(parsedArgs, argument, convertedValue);
}
}
fillDefaults(parsedArgs);
List<String> mandatoryCopy = new ArrayList<>(mandatory);
mandatoryCopy.removeAll(parsedArgs.keySet());
if(!mandatoryCopy.isEmpty()) {
errors.add(
new IllegalArgumentException(
String.format("Argument(s) '%1$s' required", StringUtils.join(mandatoryCopy, ", "))
)
);
}
return errors.isEmpty();
}
/**
* Print usage of all arguments with {@link #prefix}. In this format:
*
* $prefix.help.usage
* $prefix.help.header
*
* Options:
* --$prefix.arg.argumentX.name=[$prefix.arg.argumentX.metavar]
* $prefix.arg.argumentX.help
*
* --$prefix.arg.argumentY.name=[$prefix.arg.argumentY.metavar]
* $prefix.arg.argumentY.help
*
* $prefix.help.footer
*
* @return formatted string with usage
*/
public String getUsage() {
StringBuilder help = new StringBuilder(String.format("Options:%n"));
for(String arg : getPrefixArguments()) {
Argument argument = this.arguments.get(arg);
help.append(
String.format(
" --%s%n",
arg + (argument.getType() != Argument.Type.NO_ARGUMENT ? "=[" + argument.getMetavar() + "]" : "")
)
);
for (
String s : argument.getHelp().replace(
"@CLI_PRM_DEFAULT@",
StringUtils.defaultString(argument.getDefaultValue())
).replace(
"@CLI_PRM_PATTERN@",
argument.getMatcher().pattern()
).split("\n")
) {
help.append(String.format(" %s%n", s));
}
help.append(String.format("%n"));
}
return doSubstitutions(
String.format("%1$s%n%2$s%n%n%3$s%4$s%n",
properties.getProperty(
prefix + ".help.usage",
(String) defaultProperties.get("help.usage")
),
properties.getProperty(
prefix + ".help.header",
(String) defaultProperties.get("help.header")
),
help.toString(),
properties.getProperty(
prefix + ".help.footer",
(String) defaultProperties.get("help.footer")
)
)
);
}
/**
* Return all registered substitutions
* @return registered substitutions
*/
public Map<String, String> getSubstitutions() {
return substitutions;
}
/**
* Go through all arguments which has default value. If such argument is not in {@code argMap} put it there.
*
* @param argMap map of converted command line arguments
*/
private void fillDefaults(Map<String, Object> argMap) {
for(Argument arg : arguments.values()) {
if (!argMap.containsKey(arg.getName()) && arg.getDefaultValue() != null) {
putValue(
argMap,
arg,
StringValueConverter.getObjectValueByString(
arg.getValueType(),
doSubstitutions(arg.getDefaultValue())
)
);
}
}
}
/**
* Put {@code value} into {@code argMap}. If {@code arg} is multivalue put a List with {@code value}.
*
* @param argMap map of converted command line arguments
* @param arg argument to be put in map
* @param value value of argument
*/
private void putValue(Map<String, Object> argMap, Argument arg, Object value) {
if (!arg.isMultivalue()) {
argMap.put(arg.getName(), value);
} else {
List<? super Object> c = (List)argMap.get(arg.getName());
if (c == null) {
c = new ArrayList<>();
argMap.put(arg.getName(), c);
}
c.add(value);
}
}
/**
* Substitute entries in string.
*/
private String doSubstitutions(String s) {
if (s != null) {
for (Map.Entry<String, String> substitution : substitutions.entrySet()) {
s = s.replaceAll(substitution.getKey(), substitution.getValue());
}
}
return s;
}
/**
* Map properties file declaration of arguemnts to {@see org.ovirt.engine.core.uutils.cli.parser.ParserArgument} class.
* All those mapping classes are stored in map {@link #arguments}. All arguments declared as mandatory are stored
* in set {@link #mandatory}, so later we can check if all mandatory arguments where specified by user.
*/
private void parseProperties() {
for(String arg : getPrefixArguments()) {
Argument argument = new Argument();
argument.setName(
getArgAttrValue(arg, "name")
);
if (argument.getName() == null) {
throw new IllegalArgumentException(
String.format("Invalid configuration. Parameter '%1$s' has no name", arg)
);
}
argument.setMetavar(
getArgAttrValue(arg, "metavar")
);
argument.setHelp(
getArgAttrValue(arg, "help")
);
argument.setDefaultValue(
getArgAttrValue(arg, "default")
);
argument.setValue(
getArgAttrValue(arg, "value")
);
argument.setMultivalue(
Boolean.parseBoolean(
getArgAttrValue(arg, "multivalue")
)
);
argument.setMandatory(
Boolean.parseBoolean(
getArgAttrValue(arg, "mandatory")
)
);
argument.setType(
Argument.Type.valueOfIgnoreCase(
getArgAttrValue(arg, "type")
)
);
argument.setMatcher(
Pattern.compile(
getArgAttrValue(arg, "matcher")
)
);
try {
argument.setValueType(
Class.forName(
getArgAttrValue(arg, "valuetype"),
true,
Thread.currentThread().getContextClassLoader()
)
);
} catch (ClassNotFoundException ex) {
throw new IllegalArgumentException(
String.format("Can't convert argument: '%1$s'. Please check valuetype in properties file.", arg)
);
}
if (argument.isMandatory()) {
mandatory.add(argument.getName());
}
arguments.put(argument.getName(), argument);
}
}
/**
* Return set of argument from properties file names which starts with {@link #prefix}
*
* @return set of argument from properties file names which starts with {@link #prefix}
*/
private Set<String> getPrefixArguments() {
Set<String> args = new TreeSet<>();
for (String argName : this.properties.stringPropertyNames()) {
String[] param = argName.split("\\.");
if((param.length > 1 && !param[1].equals("arg")) || !param[0].equals(this.prefix)) {
continue;
}
if (param.length < 4) {
throw new IllegalArgumentException(
String.format("Invalid configuration. Invalid structure for parameter'%1$s'", argName)
);
}
args.add(param[2]);
}
return args;
}
private String[] parseArgument(String arg) {
String [] splitArg = arg.split("=", 2);
if(splitArg.length < 2) {
return new String[] {splitArg[0], null};
}
return splitArg;
}
private String getArgAttrValue(String argument, String key) {
return properties.getProperty(
prefix + ".arg." + argument + "." + key,
defaultProperties.getProperty("arg." + key)
);
}
private static Properties loadProperties(InputStream resource) {
try (
Reader is = new InputStreamReader(resource, StandardCharsets.UTF_8);
) {
Properties prop = new Properties();
prop.load(is);
return prop;
} catch (IOException ioe) {
throw new RuntimeException(ioe);
}
}
}