/*
* Copyright 2011 ZerothAngel <zerothangel@tyrannyofheaven.org>
*
* 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 org.tyrannyofheaven.bukkit.util.command;
import static org.tyrannyofheaven.bukkit.util.permissions.PermissionUtils.requireAllPermissions;
import static org.tyrannyofheaven.bukkit.util.permissions.PermissionUtils.requireOnePermission;
import java.lang.annotation.Annotation;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
import java.util.WeakHashMap;
import org.bukkit.Server;
import org.bukkit.command.CommandSender;
import org.bukkit.command.PluginCommand;
import org.bukkit.command.TabExecutor;
import org.bukkit.plugin.Plugin;
import org.bukkit.plugin.java.JavaPlugin;
import org.bukkit.util.StringUtil;
import org.tyrannyofheaven.bukkit.util.ToHLoggingUtils;
/**
* The main class that drives annotation-driven command parsing.
*
* @author zerothangel
*/
final class HandlerExecutor<T extends Plugin> {
private static final Map<Class<?>, Class<?>> primitiveWrappers;
private static final Set<Class<?>> supportedParameterTypes;
private final T plugin;
private final UsageOptions usageOptions;
private final Map<String, CommandMetaData> commandMap = new HashMap<>();
private final Map<Object, HandlerExecutor<T>> subCommandMap = new WeakHashMap<>();
private final Set<String> commandList = new TreeSet<>();
static {
// Build map of primitives to primitive wrappers
Map<Class<?>, Class<?>> wrappers = new HashMap<>();
wrappers.put(Boolean.TYPE, Boolean.class);
wrappers.put(Byte.TYPE, Byte.class);
wrappers.put(Short.TYPE, Short.class);
wrappers.put(Integer.TYPE, Integer.class);
wrappers.put(Long.TYPE, Long.class);
wrappers.put(Float.TYPE, Float.class);
wrappers.put(Double.TYPE, Double.class);
primitiveWrappers = Collections.unmodifiableMap(wrappers);
// Build set of supported parameter types
Set<Class<?>> types = new HashSet<>();
types.add(String.class);
for (Map.Entry<Class<?>, Class<?>> me : primitiveWrappers.entrySet()) {
types.add(me.getKey());
types.add(me.getValue());
}
supportedParameterTypes = Collections.unmodifiableSet(types);
}
/**
* Create a HandlerExecutor instance.
*
* @param plugin the associated plugin
* @param usageOptions UsageOptions to use with the HelpBuilder
* @param handlers handler objects
*/
HandlerExecutor(T plugin, UsageOptions usageOptions, Object... handlers) {
if (plugin == null)
throw new IllegalArgumentException("plugin cannot be null");
if (usageOptions == null)
throw new IllegalArgumentException("usageOptions cannot be null");
if (handlers == null)
handlers = new Object[0];
this.plugin = plugin;
this.usageOptions = usageOptions;
processHandlers(handlers);
}
/**
* Create a HandlerExecutor instance.
*
* @param plugin the associated plugin
* @param usageOptions UsageOptions to use with the HelpBuilder
* @param handlers handler objects
*/
HandlerExecutor(T plugin, Object... handlers) {
this(plugin, new DefaultUsageOptions(), handlers);
}
// Analyze each handler object and create/store the appropriate metadata
// classes.
private void processHandlers(Object[] handlers) {
for (Object handler : handlers) {
Class<?> clazz = handler.getClass();
// Scan each method
for (Method method : clazz.getMethods()) {
// Handle @Require if present
Require require = method.getAnnotation(Require.class);
String[] permissions = new String[0];
boolean requireAll = false;
boolean checkNegations = false;
if (require != null) {
permissions = require.value();
requireAll = require.all();
checkNegations = require.checkNegations();
}
// @Command or @SubCommand present?
Command command = method.getAnnotation(Command.class);
if (command != null) {
// Handle @Command
List<MethodParameter> options = new ArrayList<>();
boolean hasLabel = false;
boolean hasRest = false; // There can be only one!
// Scan each parameter
for (int i = 0; i < method.getParameterTypes().length; i++) {
Class<?> paramType = method.getParameterTypes()[i];
Annotation[] anns = method.getParameterAnnotations()[i];
MethodParameter ma = null;
// Special parameter type?
if (paramType.isAssignableFrom(Server.class)) {
ma = new SpecialParameter(SpecialParameter.Type.SERVER);
}
else if (paramType.isAssignableFrom(plugin.getClass())) {
ma = new SpecialParameter(SpecialParameter.Type.PLUGIN);
}
else if (paramType.isAssignableFrom(CommandSender.class)) {
ma = new SpecialParameter(SpecialParameter.Type.COMMAND_SENDER);
}
else if (paramType.isAssignableFrom(HelpBuilder.class)) {
ma = new SpecialParameter(SpecialParameter.Type.USAGE_BUILDER);
}
else if (paramType.isAssignableFrom(CommandSession.class)) {
ma = new SpecialParameter(SpecialParameter.Type.SESSION);
}
else if (paramType.isArray() && paramType.getComponentType() == String.class) {
if (hasRest) {
throw new CommandException("Method already has a String[] parameter (%s#%s)", handler.getClass().getName(), method.getName());
}
ma = new SpecialParameter(SpecialParameter.Type.REST);
hasRest = true;
}
else {
// Grab the @Option and @Session annotations
Option optAnn = null;
Session sessAnn = null;
for (Annotation ann : anns) {
if (ann instanceof Option) {
optAnn = (Option)ann;
}
else if (ann instanceof Session) {
sessAnn = (Session)ann;
}
}
// Both must not be present
if (optAnn != null && sessAnn != null) {
throw new CommandException("Parameter cannot have both @Option and @Session annotations (%s#%s)", handler.getClass().getName(), method.getName());
}
else if (sessAnn != null) {
// @Session
ma = new SessionParameter(sessAnn.value(), paramType);
}
else if (optAnn != null) {
// @Option
// Supported parameter type?
if (!supportedParameterTypes.contains(paramType)) {
throw new CommandException("Unsupported parameter type: %s (%s#%s)", paramType, handler.getClass().getName(), method.getName());
}
ma = new OptionMetaData(optAnn.value(), optAnn.valueName(), paramType, optAnn.optional(), optAnn.nullable(), optAnn.completer());
}
else {
// Not annotated at all
// Is it a String parameter?
if (paramType == String.class) {
if (hasLabel) {
throw new CommandException("Method already has an unannotated String parameter (%s#%s)", handler.getClass().getName(), method.getName());
}
ma = new SpecialParameter(SpecialParameter.Type.LABEL);
hasLabel = true;
}
else
throw new CommandException("Non-special parameters must be annotated with @Option (%s#%s)", handler.getClass().getName(), method.getName());
}
}
options.add(ma);
}
// Some validation of option ordering
// Flags (-f, --flag) can appear anywhere.
// Optional arguments must follow positional ones.
// Nullable arguments must follow non-nullable ones.
List<MethodParameter> reversed = new ArrayList<>(options);
Collections.reverse(reversed); // easier to do this in reverse
boolean positional = false; // true if positional arguments have started
boolean nonNullable = false; // true if non-nullable arguments have started
for (MethodParameter ma : reversed) {
if (!(ma instanceof OptionMetaData)) continue;
OptionMetaData omd = (OptionMetaData)ma;
if (omd.isArgument()) {
if (!omd.isOptional()) {
positional = true;
}
else if (positional) {
throw new CommandException("Optional parameters must follow all non-optional ones (%s#%s)", handler.getClass().getName(), method.getName());
}
if (!omd.isNullable()) {
nonNullable = true;
}
else if (nonNullable) {
throw new CommandException("Nullable parameters must follow all non-nullable ones (%s#%s)", handler.getClass().getName(), method.getName());
}
}
}
CommandMetaData cmd = new CommandMetaData(handler, method, options, permissions, requireAll, checkNegations, command.description(), hasRest, hasRest ? command.varargs() : null, hasRest ? command.completer() : null);
for (String commandName : command.value()) {
if (commandMap.put(commandName, cmd) != null) {
throw new CommandException("Duplicate command: %s (%s#%s)", commandName, handler.getClass().getName(), method.getName());
}
}
// Track unaliased name for easy registration
// Dupes would have been handled above
commandList.add(command.value()[0]);
}
}
}
}
// Convert string to boolean (a little more friendlier than Boolean.valueOf(String))
private boolean toBoolean(String text) {
text = text.trim().toLowerCase();
if ("true".equals(text) || "t".equals(text) || "yes".equals(text) || "y".equals(text) || "on".equals(text))
return true;
else if ("false".equals(text) || "f".equals(text) || "no".equals(text) || "n".equals(text) || "off".equals(text))
return false;
else
throw new IllegalArgumentException("Cannot convert string to boolean");
}
// Given parsed arguments and metadata, create an argument list suitable
// for reflective invoke.
private Object[] buildMethodArgs(CommandMetaData cmd, CommandSender sender, ParsedArgs pa, String label, InvocationChain invChain, CommandSession session, Set<String> possibleCommands) throws Throwable {
List<Object> result = new ArrayList<>(cmd.getParameters().size());
for (MethodParameter mp : cmd.getParameters()) {
if (mp instanceof SpecialParameter) {
SpecialParameter sp = (SpecialParameter)mp;
if (sp.getType() == SpecialParameter.Type.SERVER) {
result.add(plugin.getServer());
}
else if (sp.getType() == SpecialParameter.Type.PLUGIN) {
result.add(plugin);
}
else if (sp.getType() == SpecialParameter.Type.COMMAND_SENDER) {
result.add(sender);
}
else if (sp.getType() == SpecialParameter.Type.LABEL) {
result.add(label);
}
else if (sp.getType() == SpecialParameter.Type.USAGE_BUILDER) {
result.add(getHelpBuilder(invChain, possibleCommands));
}
else if (sp.getType() == SpecialParameter.Type.SESSION) {
result.add(session);
}
else if (sp.getType() == SpecialParameter.Type.REST) {
result.add(pa.getRest());
}
else {
throw new AssertionError("Unknown SpecialParameter type");
}
}
else if (mp instanceof OptionMetaData) {
OptionMetaData omd = (OptionMetaData)mp;
String text = pa.getOption(omd.getName());
// If Boolean or boolean, treat specially
if (omd.getType() == Boolean.class || omd.getType() == Boolean.TYPE) {
if (omd.isArgument()) {
if (text != null) {
try {
result.add(toBoolean(text));
}
catch (IllegalArgumentException e) {
throw new ParseException("Invalid boolean: %s", omd.getName());
}
}
else if (!omd.isOptional()) {
if (omd.isNullable()) {
result.add(null);
}
else {
// Missing positional argument
throw new ParseException("Missing argument: %s", omd.getName());
}
}
else {
// Flag not specified
// Set to false if primitive, null if wrapper
if (omd.getType() == Boolean.TYPE) {
result.add(Boolean.FALSE);
}
else {
result.add(null);
}
}
}
else {
// Flag
result.add(Boolean.valueOf(text != null));
}
}
else if (text != null) {
if (omd.getType() == String.class) {
// Nothing to convert
result.add(text);
}
else {
// Use .valueOf(String) to convert
Class<?> paramType = omd.getType();
// Primitives don't have .valueOf(String)
Class<?> newType = primitiveWrappers.get(paramType);
if (newType != null)
paramType = newType;
try {
Method valueOf = paramType.getMethod("valueOf", String.class);
Object value = valueOf.invoke(null, text);
result.add(value);
}
catch (InvocationTargetException e) {
// Unwrap, see if it's a NumberFormatException
if (e.getCause() instanceof NumberFormatException) {
// Complain
throw new ParseException("Invalid number: %s", omd.getName());
}
else {
// Re-throw
throw e.getCause();
}
}
}
}
else {
if (omd.isArgument() && !omd.isOptional()) {
if (!omd.isNullable()) {
// Missing positional argument
throw new ParseException("Missing argument: %s", omd.getName());
}
}
result.add(null);
}
}
else if (mp instanceof SessionParameter) {
SessionParameter sp = (SessionParameter)mp;
result.add(session.getValue(sp.getName(), sp.getType()));
}
else {
throw new AssertionError("Unknown MethodParameter type");
}
}
return result.toArray();
}
/**
* Executes the named command.
*
* @param sender the command sender
* @param name the name of the command to execute
* @param args command arguments
*/
void execute(CommandSender sender, String name, String label, String[] args) throws Throwable {
execute(sender, name, label, args, null, null);
}
/**
* Executes the named command.
*
* @param sender the command sender
* @param name the name of the command to execute
* @param args command arguments
* @param invChain an InvocationChain or null
* @param session a CommandSession or null
*/
void execute(CommandSender sender, String name, String label, String[] args, InvocationChain invChain, CommandSession session) throws Throwable {
if (invChain == null)
invChain = new InvocationChain();
if (session == null)
session = new CommandSession();
CommandMetaData cmd = commandMap.get(name);
if (cmd == null)
throw new ParseException("Unknown command: %s", name);
// Check permissions
if (cmd.isRequireAll()) {
requireAllPermissions(sender, cmd.getPermissions());
}
else {
requireOnePermission(sender, cmd.isCheckNegations(), cmd.getPermissions());
}
// Save into chain
invChain.addInvocation(label, cmd);
ParsedArgs pa = new ParsedArgs();
pa.parse(cmd, args);
if (!cmd.hasRest() && pa.getRest().length > 0)
throw new ParseException("Too many arguments");
Object[] methodArgs = buildMethodArgs(cmd, sender, pa, label, invChain, session, null);
Object nextHandler = null;
try {
nextHandler = cmd.getMethod().invoke(cmd.getHandler(), methodArgs);
}
catch (InvocationTargetException e) {
// Unwrap exception, re-throw
throw e.getCause();
}
if (nextHandler != null) {
// Handle a sub-command
args = pa.getRest();
if (args.length >= 1) {
// Check HandlerExecutor cache
HandlerExecutor<T> he = handlerExecutorFor(nextHandler);
// Chain to next handler
String subName = args[0];
args = Arrays.copyOfRange(args, 1, args.length);
he.execute(sender, subName, subName, args, invChain, session);
}
}
}
// Add the named CommandMetaData to an InvocationChain
void fillInvocationChain(InvocationChain invChain, String label) {
CommandMetaData cmd = commandMap.get(label);
if (cmd == null)
throw new IllegalArgumentException("Unknown command: " + label);
invChain.addInvocation(label, cmd);
}
// Retrieve cached HandlerExecutor for given handler object, creating
// one if it doesn't exist
synchronized HandlerExecutor<T> handlerExecutorFor(Object handler) {
// Check HandlerExecutor cache
HandlerExecutor<T> he = subCommandMap.get(handler);
if (he == null) {
// No HandlerExecutor yet, create a new one
he = new HandlerExecutor<>(plugin, usageOptions, handler);
subCommandMap.put(handler, he);
}
return he;
}
// Create a HelpBuilder associated with this HandlerExecutor
HelpBuilder getHelpBuilder(InvocationChain rootInvocationChain, Set<String> possibleCommands) {
return new HelpBuilder(this, rootInvocationChain, usageOptions, possibleCommands);
}
// Register top-level commands
void registerCommands(TabExecutor executor) {
for (String name : commandList) {
PluginCommand command = ((JavaPlugin)plugin).getCommand(name);
if (command == null) {
ToHLoggingUtils.warn(plugin, "Command '%s' not found in plugin.yml -- ignoring", name);
continue;
}
command.setExecutor(executor);
command.setTabCompleter(executor);
}
}
/**
* Determine possible completions for the last argument.
*
* @param sender the command sender
* @param name the name of the command to execute
* @param args command arguments
* @param invChain an InvocationChain or null
* @param session a CommandSession or null
* @param typeCompleterRegistry the (global) TypeCompleter registry
* @return list of possible completions
*/
List<String> getTabCompletions(CommandSender sender, String name, String label, String[] args, InvocationChain invChain, CommandSession session, Map<String, TypeCompleter> typeCompleterRegistry) throws Throwable {
if (invChain == null)
invChain = new InvocationChain();
if (session == null)
session = new CommandSession();
// Isolate query argument (last argument)
String query;
String[] argsNoQuery;
if (args.length > 0) {
// Have at least one
query = args[args.length - 1];
argsNoQuery = Arrays.copyOfRange(args, 0, args.length - 1);
}
else {
query = "";
argsNoQuery = args;
}
CommandMetaData cmd = commandMap.get(name);
if (cmd == null)
throw new ParseException("Unknown command: %s", name);
// Check permissions
if (cmd.isRequireAll()) {
requireAllPermissions(sender, cmd.getPermissions());
}
else {
requireOnePermission(sender, cmd.isCheckNegations(), cmd.getPermissions());
}
// Save into chain
invChain.addInvocation(label, cmd);
// Tab completion on cmd.getFlagOptions() and cmd.getPositionalArguments()
ParsedArgs pa = new ParsedArgs();
OptionMetaData missingValue;
boolean consumedAll;
try {
pa.parse(cmd, argsNoQuery);
missingValue = pa.getUnparsedArgument(); // possible because of nullable
consumedAll = pa.getRest().length == 0;
}
catch (UnknownFlagException e) {
// Tab-completion ain't gonna help
return Collections.emptyList();
}
catch (MissingValueException e) {
missingValue = e.getOptionMetaData();
consumedAll = true;
}
// Is it the start of a flag?
if (consumedAll && !pa.isParsedPositional() && !OptionMetaData.isArgument(query)) {
List<String> source = new ArrayList<>();
source.add("--"); // explicit end of flags
for (OptionMetaData omd : cmd.getFlagOptions()) {
boolean found = false;
for (String flag : omd.getNames()) {
if (pa.getOptions().containsKey(flag)) {
found = true;
break;
}
}
if (found) {
// Skip this one (we don't support multiple flags)
continue;
}
source.addAll(Arrays.asList(omd.getNames()));
}
List<String> result = new ArrayList<>();
StringUtil.copyPartialMatches(query, source, result);
return result;
}
if (missingValue != null) {
// Use missing value's type to get candidates
List<String> result = new ArrayList<>();
addCompletions(typeCompleterRegistry, missingValue, sender, query, result);
return result;
}
// Check if sub-command
if (cmd.getMethod().getReturnType() != Void.TYPE) {
// Sub-command, attempt to execute it. It better not have side-effects!
Set<String> possibleCommands = new HashSet<>();
Object[] methodArgs = buildMethodArgs(cmd, sender, pa, label, invChain, session, possibleCommands);
Object nextHandler;
try {
nextHandler = cmd.getMethod().invoke(cmd.getHandler(), methodArgs);
}
catch (InvocationTargetException e) {
throw e.getCause();
}
if (nextHandler != null) {
args = pa.getRest();
if (args.length >= 1) {
HandlerExecutor<T> he = handlerExecutorFor(nextHandler);
// Chain to next
String subName = args[0];
args = Arrays.copyOfRange(args, 1, args.length + 1); // room for query
args[args.length - 1] = query; // stuff query argument back in
return he.getTabCompletions(sender, subName, subName, args, invChain, session, typeCompleterRegistry);
}
}
// Relying on HelpBuilder to have filled out the blanks
List<String> result = new ArrayList<>();
StringUtil.copyPartialMatches(query, possibleCommands, result);
return result;
}
// Have a varargs completer?
if (cmd.getCompleter() != null) {
List<String> result = new ArrayList<>();
// Determine suitable TypeCompleter
TypeCompleter typeCompleter = null;
String arg = null;
String completerName = cmd.getCompleter();
// Split arguments, if present
String[] parts = completerName.split(":", 2);
if (parts.length == 2) {
completerName = parts[0];
arg = parts[1];
}
typeCompleter = typeCompleterRegistry.get(completerName);
if (typeCompleter != null) {
result.addAll(typeCompleter.complete(String.class, arg, sender, query));
}
return result;
}
// Nothing else
return Collections.emptyList();
}
private void addCompletions(Map<String, TypeCompleter> typeCompleterRegistry, OptionMetaData omd, CommandSender sender, String partial, List<String> destination) {
// Determine suitable TypeCompleter
TypeCompleter typeCompleter = null;
String arg = null;
String completerName = omd.getCompleter();
if (completerName != null) {
// Split arguments, if present
String[] parts = completerName.split(":", 2);
if (parts.length == 2) {
completerName = parts[0];
arg = parts[1];
}
typeCompleter = typeCompleterRegistry.get(completerName);
}
if (typeCompleter != null) {
destination.addAll(typeCompleter.complete(omd.getType(), arg, sender, partial));
}
else {
// Use values based on type.
if (omd.getType() == Boolean.class || omd.getType() == Boolean.TYPE) {
// Easy one
List<String> source = new ArrayList<>();
source.add("true");
source.add("false");
StringUtil.copyPartialMatches(partial, source, destination);
}
else if (partial == null || partial.length() == 0){
// Drop a hint
destination.add(String.format("<%s>", omd.isArgument() ? omd.getName() : omd.getValueName()));
}
}
}
}