package aQute.lib.getopt;
import java.lang.reflect.Method;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Formatter;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.TreeMap;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import aQute.configurable.Config;
import aQute.configurable.Configurable;
import aQute.lib.justif.Justif;
import aQute.lib.markdown.MarkdownFormatter;
import aQute.libg.generics.Create;
import aQute.libg.reporter.ReporterMessages;
import aQute.service.reporter.Reporter;
/**
* Helps parsing command lines. This class takes target object, a primary
* command, and a list of arguments. It will then find the command in the target
* object. The method of this command must start with a "_" and take an
* parameter of Options type. Usually this is an interface that extends Options.
* The methods on this interface are options or flags (when they return
* boolean).
*/
@SuppressWarnings("unchecked")
public class CommandLine {
static int LINELENGTH = 60;
static Pattern ASSIGNMENT = Pattern.compile("(\\w[\\w\\d]*+)\\s*=\\s*([^\\s]+)\\s*");
Reporter reporter;
Justif justif = new Justif(80, 30, 32, 70);
CommandLineMessages msg;
private Object result;
class Option {
public char shortcut;
public String name;
public String paramType;
public String description;
public boolean required;
}
public CommandLine(Reporter reporter) {
this.reporter = reporter;
msg = ReporterMessages.base(reporter, CommandLineMessages.class);
}
/**
* Execute a command in a target object with a set of options and arguments
* and returns help text if something fails. Errors are reported.
*/
public String execute(Object target, String cmd, List<String> input) throws Exception {
if (cmd.equals("help")) {
StringBuilder sb = new StringBuilder();
Formatter f = new Formatter(sb);
if (input.isEmpty())
help(f, target);
else {
for (String s : input) {
help(f, target, s);
}
}
f.flush();
justif.wrap(sb);
return sb.toString();
}
//
// Find the appropriate method
//
List<String> arguments = new ArrayList<String>(input);
Map<String,Method> commands = getCommands(target);
Method m = commands.get(cmd);
if (m == null) {
msg.NoSuchCommand_(cmd);
return help(target, null, null);
}
//
// Parse the options
//
Class< ? extends Options> optionClass = (Class< ? extends Options>) m.getParameterTypes()[0];
Options options = getOptions(optionClass, arguments);
if (options == null) {
// had some error, already reported
return help(target, cmd, null);
}
// Check if we have an @Arguments annotation that
// provides patterns for the remainder arguments
Arguments argumentsAnnotation = optionClass.getAnnotation(Arguments.class);
if (argumentsAnnotation != null) {
String[] patterns = argumentsAnnotation.arg();
// Check for commands without any arguments
if (patterns.length == 0 && arguments.size() > 0) {
msg.TooManyArguments_(arguments);
return help(target, cmd, null);
}
// Match the patterns to the given command line
int i = 0;
for (; i < patterns.length; i++) {
String pattern = patterns[i];
boolean optional = pattern.matches("\\[.*\\]");
// Handle vararg
if (pattern.contains("...")) {
i = Integer.MAX_VALUE;
break;
}
// Check if we're running out of args
if (i >= arguments.size()) {
if (!optional) {
msg.MissingArgument_(patterns[i]);
return help(target, cmd, optionClass);
}
}
}
// Check if we have unconsumed arguments left
if (i < arguments.size()) {
msg.TooManyArguments_(arguments);
return help(target, cmd, optionClass);
}
}
if (reporter.getErrors().size() == 0) {
m.setAccessible(true);
result = m.invoke(target, options);
return null;
}
return help(target, cmd, optionClass);
}
public void generateDocumentation(Object target, Appendable out) {
MarkdownFormatter f = new MarkdownFormatter(out);
f.h1("Available Commands:");
Map<String,Method> commands = getCommands(target);
for (String command : commands.keySet()) {
Class< ? extends Options> specification = (Class< ? extends Options>) commands.get(command)
.getParameterTypes()[0];
Map<String,Method> options = getOptions(specification);
Arguments patterns = specification.getAnnotation(Arguments.class);
f.h2(command);
Description descr = specification.getAnnotation(Description.class);
if (descr != null) {
f.format("%s%n%n", descr.value());
}
f.h3("Synopsis:");
f.code(getSynopsis(command, options, patterns));
if (!options.isEmpty()) {
f.h3("Options:");
for (Entry<String,Method> entry : options.entrySet()) {
Option option = getOption(entry.getKey(), entry.getValue());
f.inlineCode("%s -%s --%s %s%s", option.required ? " " : "[", //
option.shortcut, //
option.name, option.paramType, //
option.required ? " " : "]");
if (option.description != null) {
f.format("%s", option.description);
f.endP();
}
}
f.format("%n");
}
}
f.flush();
}
private String help(Object target, String cmd, Class< ? extends Options> type) throws Exception {
StringBuilder sb = new StringBuilder();
Formatter f = new Formatter(sb);
if (cmd == null)
help(f, target);
else if (type == null)
help(f, target, cmd);
else
help(f, target, cmd, type);
f.flush();
justif.wrap(sb);
return sb.toString();
}
/**
* Parse the options in a command line and return an interface that provides
* the options from this command line. This will parse up to (and including)
* -- or an argument that does not start with -
*/
public <T extends Options> T getOptions(Class<T> specification, List<String> arguments) throws Exception {
Map<String,String> properties = Create.map();
Map<String,Object> values = new HashMap<String,Object>();
Map<String,Method> options = getOptions(specification);
argloop: while (arguments.size() > 0) {
String option = arguments.get(0);
if (option.startsWith("-")) {
arguments.remove(0);
if (option.startsWith("--")) {
if ("--".equals(option))
break argloop;
// Full named option, e.g. --output
String name = option.substring(2);
Method m = options.get(name);
if (m == null) { // Maybe due to capitalization modif
m = options.get(Character.toLowerCase(name.charAt(0)) + name.substring(1));
}
if (m == null)
msg.UnrecognizedOption_(name);
else
assignOptionValue(values, m, arguments, true);
} else {
// Set of single character named options like -a
charloop: for (int j = 1; j < option.length(); j++) {
char optionChar = option.charAt(j);
for (Entry<String,Method> entry : options.entrySet()) {
if (entry.getKey().charAt(0) == optionChar) {
boolean last = (j + 1) >= option.length();
assignOptionValue(values, entry.getValue(), arguments, last);
continue charloop;
}
}
msg.UnrecognizedOption_(optionChar + "");
}
}
} else {
Matcher m = ASSIGNMENT.matcher(option);
if (m.matches()) {
properties.put(m.group(1), m.group(2));
}
break;
}
}
// check if all required elements are set
for (Entry<String,Method> entry : options.entrySet()) {
Method m = entry.getValue();
String name = entry.getKey();
if (!values.containsKey(name) && isMandatory(m))
msg.OptionNotSet_(name);
}
values.put(".", arguments);
values.put(".arguments", arguments);
values.put(".command", this);
values.put(".properties", properties);
return Configurable.createConfigurable(specification, values);
}
/**
* Answer a list of the options specified in an options interface
*/
private Map<String,Method> getOptions(Class< ? extends Options> interf) {
Map<String,Method> map = new TreeMap<String,Method>(String.CASE_INSENSITIVE_ORDER);
for (Method m : interf.getMethods()) {
if (m.getName().startsWith("_"))
continue;
String name;
Config cfg = m.getAnnotation(Config.class);
if (cfg == null || cfg.id() == null || cfg.id().equals(Config.NULL))
name = m.getName();
else
name = cfg.id();
map.put(name, m);
}
// In case two options have the same first char, uppercase one of them
// In case 3+ --------------------------------, throw an error
char prevChar = '\0';
boolean throwOnNextMatch = false;
Map<String,Method> toModify = new HashMap<String,Method>();
for (String name : map.keySet()) {
if (Character.toLowerCase(name.charAt(0)) != name.charAt(0)) { //
throw new Error("Only commands with lower case first char are acceptable (" + name + ")");
}
if (Character.toLowerCase(name.charAt(0)) == prevChar) {
if (throwOnNextMatch) {
throw new Error("3 options with same first letter (one is: " + name + ")");
} else {
toModify.put(name, map.get(name));
throwOnNextMatch = true;
}
} else {
throwOnNextMatch = false;
prevChar = name.charAt(0);
}
}
for (String name : toModify.keySet()) {
map.remove(name);
String newName = Character.toUpperCase(name.charAt(0)) + name.substring(1);
map.put(newName, toModify.get(name));
}
return map;
}
/**
* Assign an option, must handle flags, parameters, and parameters that can
* happen multiple times.
*
* @param options The command line map
* @param args the args input
* @param m the selected method for this option
* @param last if this is the last in a multi single character option
*/
public void assignOptionValue(Map<String,Object> options, Method m, List<String> args, boolean last) {
String name = m.getName();
Type type = m.getGenericReturnType();
if (isOption(m)) {
// The option is a simple flag
options.put(name, true);
} else {
// The option is followed by an argument
if (!last) {
msg.Option__WithArgumentNotLastInAbbreviation_(name, name.charAt(0), getTypeDescriptor(type));
return;
}
if (args.isEmpty()) {
msg.MissingArgument__(name, name.charAt(0));
return;
}
String parameter = args.remove(0);
if (Collection.class.isAssignableFrom(m.getReturnType())) {
Collection<Object> optionValues = (Collection<Object>) options.get(m.getName());
if (optionValues == null) {
optionValues = new ArrayList<Object>();
options.put(name, optionValues);
}
optionValues.add(parameter);
} else {
if (options.containsKey(name)) {
msg.OptionCanOnlyOccurOnce_(name);
return;
}
options.put(name, parameter);
}
}
}
/**
* Provide a help text.
*/
public void help(Formatter f, Object target, String cmd, Class< ? extends Options> specification) {
Description descr = specification.getAnnotation(Description.class);
Arguments patterns = specification.getAnnotation(Arguments.class);
String description = descr == null ? "" : descr.value();
f.format("%nNAME%n %s \t0- \t1%s%n%n", cmd, description);
Map<String,Method> options = getOptions(specification);
f.format("SYNOPSIS%n");
f.format(getSynopsis(cmd, options, patterns));
help(f, specification, "OPTIONS");
}
private void help(Formatter f, Class< ? extends Options> specification, String title) {
Map<String,Method> options = getOptions(specification);
if (!options.isEmpty()) {
f.format("%n%s%n%n", title);
for (Entry<String,Method> entry : options.entrySet()) {
Option option = getOption(entry.getKey(), entry.getValue());
f.format(" %s -%s, --%s %s%s \t0- \t1%s%n", option.required ? " " : "[", //
option.shortcut, //
option.name, option.paramType, //
option.required ? " " : "]", //
option.description);
}
f.format("%n");
}
}
private Option getOption(String optionName, Method m) {
Option option = new Option();
Config cfg = m.getAnnotation(Config.class);
Description d = m.getAnnotation(Description.class);
option.shortcut = optionName.charAt(0);
option.name = Character.toLowerCase(optionName.charAt(0)) + optionName.substring(1);
option.description = cfg != null ? cfg.description() : (d == null ? "" : d.value());
option.required = isMandatory(m);
String pt = getTypeDescriptor(m.getGenericReturnType());
if (pt.length() != 0)
pt += " ";
option.paramType = pt;
return option;
}
private String getSynopsis(String cmd, Map<String,Method> options, Arguments patterns) {
StringBuilder sb = new StringBuilder();
if (options.isEmpty())
sb.append(String.format(" %s ", cmd));
else
sb.append(String.format(" %s [options] ", cmd));
if (patterns == null)
sb.append(String.format(" ...%n%n"));
else {
String del = " ";
for (String pattern : patterns.arg()) {
if (pattern.equals("..."))
sb.append(String.format("%s...", del));
else
sb.append(String.format("%s<%s>", del, pattern));
del = " ";
}
sb.append(String.format("%n"));
}
return sb.toString();
}
static Pattern LAST_PART = Pattern.compile(".*[\\$\\.]([^\\$\\.]+)");
private static String lastPart(String name) {
Matcher m = LAST_PART.matcher(name);
if (m.matches())
return m.group(1);
return name;
}
/**
* Show all commands in a target
*/
public void help(Formatter f, Object target) throws Exception {
f.format("%n");
Description descr = target.getClass().getAnnotation(Description.class);
if (descr != null) {
f.format("%s%n%n", descr.value());
}
for (Entry<String,Method> e : getCommands(target).entrySet()) {
Method m = e.getValue();
if (m.getName().startsWith("__")) {
Class< ? extends Options> options = (Class< ? extends Options>) m.getParameterTypes()[0];
help(f, options, "MAIN OPTIONS");
}
}
f.format("Available sub-commands: %n%n");
for (Entry<String,Method> e : getCommands(target).entrySet()) {
if (e.getValue().getName().startsWith("__"))
continue;
Description d = e.getValue().getAnnotation(Description.class);
String desc = " ";
if (d != null)
desc = d.value();
f.format(" %s\t0-\t1%s %n", e.getKey(), desc);
}
f.format("%n");
}
/**
* Show the full help for a given command
*/
public void help(Formatter f, Object target, String cmd) {
Method m = getCommands(target).get(cmd);
if (m == null)
f.format("No such command: %s%n", cmd);
else {
Class< ? extends Options> options = (Class< ? extends Options>) m.getParameterTypes()[0];
help(f, target, cmd, options);
}
}
/**
* Parse a class and return a list of command names
*
* @param target
* @return command names
*/
public Map<String,Method> getCommands(Object target) {
Map<String,Method> map = new TreeMap<String,Method>();
for (Method m : target.getClass().getMethods()) {
if (m.getParameterTypes().length == 1 && m.getName().startsWith("_")) {
Class< ? > clazz = m.getParameterTypes()[0];
if (Options.class.isAssignableFrom(clazz)) {
String name = m.getName().substring(1);
map.put(name, m);
}
}
}
return map;
}
/**
* Answer if the method is marked mandatory
*/
private boolean isMandatory(Method m) {
Config cfg = m.getAnnotation(Config.class);
if (cfg == null)
return false;
return cfg.required();
}
/**
* @param m
*/
private boolean isOption(Method m) {
return m.getReturnType() == boolean.class || m.getReturnType() == Boolean.class;
}
/**
* Show a type in a nice way
*/
private String getTypeDescriptor(Type type) {
if (type instanceof ParameterizedType) {
ParameterizedType pt = (ParameterizedType) type;
Type c = pt.getRawType();
if (c instanceof Class) {
if (Collection.class.isAssignableFrom((Class< ? >) c)) {
return getTypeDescriptor(pt.getActualTypeArguments()[0]) + "*";
}
}
}
if (!(type instanceof Class))
return "<>";
Class< ? > clazz = (Class< ? >) type;
if (clazz == Boolean.class || clazz == boolean.class)
return ""; // Is a flag
return "<" + lastPart(clazz.getName().toLowerCase()) + ">";
}
public Object getResult() {
return result;
}
public String subCmd(Options opts, Object target) throws Exception {
List<String> arguments = opts._arguments();
if (arguments.isEmpty()) {
Justif j = new Justif();
Formatter f = j.formatter();
help(f, target);
return j.wrap();
} else {
String cmd = arguments.remove(0);
return execute(target, cmd, arguments);
}
}
}