All Downloads are FREE. Search and download functionalities are using the official Maven repository.

aQute.lib.getopt.CommandLine Maven / Gradle / Ivy

There is a newer version: 7.1.0
Show newest version
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 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 arguments = new ArrayList<>(input);
		Map commands = getCommands(target);

		Method m = commands.get(cmd);
		if (m == null) {
			msg.NoSuchCommand_(cmd);
			return help(target, null, null);
		}

		//
		// Parse the options
		//

		Class optionClass = (Class) 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 commands = getCommands(target);
		for (String command : commands.keySet()) {
			Class specification = (Class) commands.get(command)
				.getParameterTypes()[0];
			Map 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 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 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 getOptions(Class specification, List arguments) throws Exception {
		Map properties = Create.map();
		Map values = new HashMap<>();
		Map 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 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 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 getOptions(Class interf) {
		Map map = new TreeMap<>(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 toModify = new HashMap<>();
		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 options, Method m, List 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 optionValues = (Collection) options.get(m.getName());

				if (optionValues == null) {
					optionValues = new ArrayList<>();
					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 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 options = getOptions(specification);
		f.format("SYNOPSIS%n");
		f.format(getSynopsis(cmd, options, patterns));

		help(f, specification, "OPTIONS");
	}

	private void help(Formatter f, Class specification, String title) {
		Map options = getOptions(specification);
		if (!options.isEmpty()) {
			f.format("%n%s%n%n", title);
			for (Entry 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 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 e : getCommands(target).entrySet()) {
			Method m = e.getValue();
			if (m.getName()
				.startsWith("__")) {
				Class options = (Class) m.getParameterTypes()[0];
				help(f, options, "MAIN OPTIONS");
			}
		}
		f.format("Available sub-commands: %n%n");

		for (Entry 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 options = (Class) 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 getCommands(Object target) {
		Map map = new TreeMap<>();

		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 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);
		}
	}
}