Skip to content

Instantly share code, notes, and snippets.

@Stumblinbear
Created February 11, 2016 15:36
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save Stumblinbear/c27f1fe584c130690b8f to your computer and use it in GitHub Desktop.
Save Stumblinbear/c27f1fe584c130690b8f to your computer and use it in GitHub Desktop.
An annotation-based command manager that handles arguments, argument type handling, predictions, tab completion, (sub-)*commands, error handling, help pages, and more!
import java.io.PrintWriter;
import java.io.StringWriter;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map.Entry;
import java.util.TreeMap;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.apache.commons.lang.StringUtils;
import org.apache.commons.lang.time.StopWatch;
import org.bukkit.Bukkit;
import org.bukkit.ChatColor;
import org.bukkit.OfflinePlayer;
import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandMap;
import org.bukkit.command.CommandSender;
import org.bukkit.command.PluginCommand;
import org.bukkit.command.TabCompleter;
import org.bukkit.entity.Player;
import org.bukkit.plugin.Plugin;
import org.bukkit.plugin.java.JavaPlugin;
import net.md_5.bungee.api.chat.ClickEvent;
import net.md_5.bungee.api.chat.ComponentBuilder;
import net.md_5.bungee.api.chat.HoverEvent;
/**
* Register a command with the command system!
* <br><br>
* Handles arguments, argument type handling, predictions, tab completion, sub-sub-sub-commands, error handling, help pages, and more!
* <br><br>
* CommandManager manager = new CommandManager(plugin, "Help Tag", "command", "permission node");
*
* @author Stumblinbear
*/
public class CommandManager implements TabCompleter, CommandExecutor {
static boolean GLOBAL_DEBUG = false;
@Cmd(cmd = "debug",
args = "[bool]",
argTypes = { Arg.ArgBoolean.class },
help = "Toggle debug mode.",
longhelp = "Toggle debug mode. Shows information on command usage.",
only = CommandOnly.OP,
permission = "debug")
public static CommandFinished cmdToggleDebugMode(CommandSender sender, Object[] args) {
GLOBAL_DEBUG = (args.length != 0 ? (Boolean)args[0] : !GLOBAL_DEBUG);
sender.sendMessage(ChatColor.YELLOW + "Debug mode is now: " + (GLOBAL_DEBUG ? ChatColor.GREEN + "ON" : ChatColor.RED + "OFF"));
return CommandFinished.DONE;
}
private static HashMap<Class<? extends AbstractArg<?>>, AbstractArg<?>> argInstances = new HashMap<Class<? extends AbstractArg<?>>, AbstractArg<?>>();
private ArrayList<Cmd> commands = new ArrayList<Cmd>();
private ArrayList<Method> commandMethods = new ArrayList<Method>();
String tag;
String command;
String permissionScheme;
public CommandManager(JavaPlugin plugin, String tag, String permissionScheme, String command, String... aliases) {
this.tag = tag;
this.command = command;
this.permissionScheme = permissionScheme;
// Used to inject the command without using plugin.yml
try {
final Field bukkitCommandMap = Bukkit.getServer().getClass().getDeclaredField("commandMap");
bukkitCommandMap.setAccessible(true);
CommandMap commandMap = (CommandMap)bukkitCommandMap.get(Bukkit.getServer());
Constructor<PluginCommand> c = PluginCommand.class.getDeclaredConstructor(String.class, Plugin.class);
c.setAccessible(true);
PluginCommand pluginCommand = c.newInstance(command, plugin);
pluginCommand.setTabCompleter(this);
pluginCommand.setExecutor(this);
if(aliases.length > 0)
pluginCommand.setAliases(Arrays.asList(aliases));
commandMap.register(command, pluginCommand);
loadCommandClass(this.getClass());
} catch(Exception e) { e.printStackTrace(); }
}
/**
* Parses classes for @Cmd annotations.
*/
public CommandManager loadCommandClass(Class<?> commandClass) {
try {
for(Method method : commandClass.getMethods()) {
if(method.isAnnotationPresent(Cmd.class)) {
Cmd cmd = method.getAnnotation(Cmd.class);
commands.add(cmd);
commandMethods.add(method);
}
}
} catch(SecurityException e) {
e.printStackTrace();
}
return this;
}
@Override
public List<String> onTabComplete(CommandSender sender, Command command, String alias, String[] args) {
List<String> predictions = new ArrayList<String>();
String token = (args.length == 0 ? "" : args[args.length - 1]);
for(Cmd c : commands) {
List<String> cmdPredictions = getPredicted(c, token, args.length - 1);
// Prevent duplicate entries
if(cmdPredictions != null) {
for(String str : cmdPredictions) {
if(!predictions.contains(str))
predictions.add(str);
}
}
}
return predictions;
}
/**
* Get a prediction of the next command argument.
*/
private List<String> getPredicted(Cmd c, String token, int i) {
String[] cmdArg = c.cmd().split(" ");
// If no token, return all possible commands.
if(token == "")
return Arrays.asList(new String[] { cmdArg[0] });
// If the amount of args is more than available, or it doesn't start with the token.
if(i >= cmdArg.length) {
int argNum = i - cmdArg.length;
if(argNum >= c.argTypes().length)
return null;
else{
if(!argInstances.containsKey(c.argTypes()[argNum]))
try {
argInstances.put(c.argTypes()[argNum], c.argTypes()[argNum].newInstance());
} catch (Exception e) { }
AbstractArg<?> absArg = argInstances.get(c.argTypes()[argNum]);
return absArg.getPredictions();
}
// If it doesn't start with the token.
}else if(!cmdArg[i].startsWith(token))
return null;
// It must be a match!
return Arrays.asList(new String[] { cmdArg[i] });
}
@Override
public boolean onCommand(CommandSender sender, Command cmd, String label, String[] args) {
StopWatch sw = null;
if(GLOBAL_DEBUG) {
sw = new StopWatch();
sw.start();
}
CommandFinished finishedType = runCommand(sender, args);
if(finishedType.shouldPrint()) {
sender.sendMessage(ChatColor.RED + finishedType.getErrorString());
// Do our best to predict which command was going to be used.
if(finishedType == CommandFinished.COMMAND) {
// TreeMaps automatically sort by numbers.
TreeMap<Double, Cmd> possible = new TreeMap<Double, Cmd>();
for(Cmd c : commands) {
// Reduce arg array to the shortest one.
String[] fixedArgs = new String[c.cmd().split(" ").length];
for(int i = 0; i < (args.length > fixedArgs.length ? fixedArgs.length : args.length); i++)
fixedArgs[i] = args[i];
// Combine the arguments.
String cmdArgs = StringUtils.join(fixedArgs, " ");
// Use Levenshtein Distance to get how similar the two strings are to each other. Calculate percentage with the value returned.
possible.put((1D - (StringUtils.getLevenshteinDistance(cmdArgs, c.cmd()) / (Math.max(cmdArgs.length(), c.cmd().length()) * 1D))) * 100D, c);
}
// Are there even any predictions?
if(possible.size() > 0) {
// Get the last entry. (The one with the highest possibility)
Entry<Double, Cmd> entry = possible.pollLastEntry();
sender.sendMessage("");
// Allow players to click the command in chat to add it to their chat input.
if(sender instanceof Player) {
((Player)sender).spigot().sendMessage(new ComponentBuilder(" Did you mean: ").color(net.md_5.bungee.api.ChatColor.GOLD)
.append("/" + label + " " + entry.getValue().cmd()).color(net.md_5.bungee.api.ChatColor.GRAY)
.event(new HoverEvent(HoverEvent.Action.SHOW_TEXT, new ComponentBuilder(StringUtils.join((entry.getValue().longhelp().equals("") ? entry.getValue().help() : entry.getValue().longhelp()).split("(?<=\\G.........................)"), "\n")).create()))
.event(new ClickEvent(ClickEvent.Action.SUGGEST_COMMAND, "/" + label + " " + entry.getValue().cmd()))
.append("? We're " + ((int)(entry.getKey() * 10) / 10D) + "% sure.").reset().color(net.md_5.bungee.api.ChatColor.GOLD).create());
}else
sender.sendMessage(ChatColor.GOLD + " Did you mean: " + ChatColor.GRAY + "/" + label + " " + entry.getValue().cmd() + ChatColor.GOLD + "? We're " + ((int)(entry.getKey() * 10) / 10D) + "% sure.");
sender.sendMessage("");
}
}
}
if(GLOBAL_DEBUG && sw != null) {
sw.stop();
sender.sendMessage(ChatColor.YELLOW + "Command took " + sw.getTime() + " milliseconds to complete.");
}
return true;
}
/**
* Find the best command to run and do so.
*/
public CommandFinished runCommand(CommandSender sender, String[] args) {
try {
// Display help if no args, or they use the help command
if(args.length == 0 || args[0].equalsIgnoreCase("help"))
return displayHelp(sender, args.length == 0 ? null : args);
else{
Cmd bestFit = null;
int bestFit_i = 0;
int bestFit_args = -1;
// Loop through commands until a suitable one is found
for(int i = 0; i < commands.size(); i++) {
Cmd cmd = commands.get(i);
// Split the base command and check for a match
String[] cmds = cmd.cmd().split(" ");
if(args.length >= cmds.length) {
boolean valid = true;
for(int j = 0; j < cmds.length; j++) {
if(!cmds[j].equalsIgnoreCase(args[j])) {
valid = false;
break;
}
}
if(!valid)
continue;
}else
continue;
// Check if it's better than the best fit.
if(cmd.cmd().split(" ").length > bestFit_args) {
bestFit = cmd;
bestFit_i = i;
bestFit_args = cmd.cmd().split(" ").length;
}else
continue;
}
if(bestFit != null) {
// Check the "only" argument
if(sender instanceof Player) {
if(bestFit.only() == CommandOnly.CONSOLE)
return CommandFinished.NOPLAYER;
}else if(bestFit.only() == CommandOnly.PLAYER)
return CommandFinished.NOCONSOLE;
// Check for the "op" argument and permission argument
if((bestFit.only() == CommandOnly.OP ? !sender.isOp() : false) ||
(bestFit.permission() != "" ? !sender.hasPermission(permissionScheme + "." + bestFit.permission()) : false))
return CommandFinished.PERMISSION;
// Split up the args; arguments in quotes count as a single argument.
List<Object> cmdArgList = new ArrayList<Object>();
Matcher m = Pattern.compile("(?:([^\"]\\S*)|\"(.+?)\")\\s*").matcher(StringUtils.join(args, " ").replaceFirst(bestFit.cmd(), "").trim());
for(int j = 0; m.find(); j++) {
// Apply the requested argument type.
Class<? extends AbstractArg<?>> requestedType = (j < bestFit.argTypes().length ? bestFit.argTypes()[j] : Arg.ArgString.class);
// Cache the instance.
if(!argInstances.containsKey(requestedType))
argInstances.put(requestedType, requestedType.newInstance());
AbstractArg<?> absArg = argInstances.get(requestedType);
try {
Object arg = absArg.parseArg(m.group(1) != null ? m.group(1) : m.group(2));
if(arg == null)
// Some argument parsers don't throw an exception. Just an extra precaution.
throw new CommandException(absArg.getFailure() + " (" + m.group(1) != null ? m.group(1) : m.group(2) + ")");
cmdArgList.add(arg);
} catch(Exception e) {
return CommandFinished.CUSTOM.replace(absArg.getFailure() + " (" + (m.group(1) != null ? m.group(1) : m.group(2)) + ")");
}
}
// Check that all the required arguments have been fulfilled.
Object[] cmdArgsPassed = cmdArgList.toArray(new Object[cmdArgList.size()]);
if(StringUtils.countMatches(bestFit.args(), "<") > cmdArgsPassed.length)
return CommandFinished.BADCOMMAND.replace(command + " " + bestFit.cmd() + " " + bestFit.args());
// Run the command :D
return (CommandFinished)commandMethods.get(bestFit_i).invoke(null, new Object[] { sender, (cmdArgsPassed != null ? cmdArgsPassed : null) });
}
}
} catch(InvocationTargetException e) {
e.getCause().printStackTrace();
if(e.getCause() instanceof CommandException)
return CommandFinished.CUSTOM.replace(e.getCause().getMessage());
if(GLOBAL_DEBUG)
Bukkit.broadcastMessage(ChatColor.RED + "Error: " + getTrace(e));
return CommandFinished.EXCEPTION;
} catch (Exception e) {
e.printStackTrace();
if(GLOBAL_DEBUG)
Bukkit.broadcastMessage(ChatColor.RED + "Error: " + getTrace(e));
return CommandFinished.EXCEPTION;
}
return CommandFinished.COMMAND.replace(command);
}
/**
* Display the help menu.
*/
public CommandFinished displayHelp(CommandSender sender, String[] args) {
ArrayList<String> helpList = new ArrayList<String>(); // The help message buffer
boolean specific = false; // If "help <command>"
int perPage = 8; // How many commands to show per page
int page = 0; // Which page
if(args != null && args.length != 1) {
try {
page = Integer.parseInt(args[1]) - 1;
if(page < 0) // Negative pages are bad juju.
page = 0;
} catch(Exception e) { specific = true; } // If this fails, it's probably a string. Check for a specific command.
}
String cmdLabel = null; // The label of the specific command.
if(specific && args.length != 1) {
perPage = 4; // Reduce the amount to show per page.
cmdLabel = StringUtils.join(args, " ").split(" ", 2)[1]; // Because args = "help <command>" cut out "help".
}
for(Cmd cmd : commands) {
// If looking for specific commands and it isn't the one we're looking for
if(specific && !cmd.cmd().startsWith(cmdLabel))
continue;
// Should it even show?
if(cmd.showInHelp()) {
// If it can't be used, don't show it! Simple! :D
boolean canUse = cmd.permission().equals("") ? true : (sender.hasPermission(permissionScheme + "." + cmd.permission()));
// Is it op-only?
if(cmd.only() == CommandOnly.OP)
canUse = (cmd.permission().equals("") ? sender.isOp() : canUse);
if(canUse)
helpList.add(ChatColor.GOLD + "/" + command + " " + cmd.cmd() + (cmd.args() != "" ? " " + cmd.args() : "") + ": " + ChatColor.WHITE + (specific ? (cmd.longhelp().equals("") ? cmd.help() : cmd.longhelp()) : cmd.help()));
}
}
// Make sure there's something to show.
boolean badPage = true;
if(helpList.size() >= page * perPage) {
for(int j = 0; j < perPage; j++) {
if(helpList.size() > (j + page * perPage)) {
if(j == 0)
sender.sendMessage(ChatColor.YELLOW + "--------- " + ChatColor.WHITE + tag + " Help (" + (page + 1) + "/" + (int)Math.ceil(helpList.size() / (perPage * 1F)) + ")" + ChatColor.YELLOW + " ---------------------");
sender.sendMessage(helpList.get((j + page * perPage)));
badPage = false;
}
}
}
if(badPage) {
if(specific)
return CommandFinished.CUSTOM.replace("Command unrecognized.");
else
return CommandFinished.CUSTOM.replace("Page " + (page + 1) + " does not exist in help.");
}else if(helpList.size() > (page + 1) * perPage)
sender.sendMessage(ChatColor.WHITE + "Use " + ChatColor.YELLOW + "/" + command + " help " + (page + 2) + ChatColor.WHITE + " to see more help.");
return CommandFinished.DONE;
}
/**
* Returns the string version of an exception. Helps with in-game error checking.
*/
private String getTrace(Exception e) {
StringWriter sw = new StringWriter();
PrintWriter pw = new PrintWriter(sw);
e.printStackTrace(pw);
return sw.toString();
}
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
/**
* Attach to a function to denote it as a command. You must register the class it is in as a command class before it will be used.
*/
public static @interface Cmd {
/**
* Takes in any amount of subcommands. The command handler always chooses the best-fit command.
* <br><br>
* Example:
* <br>
* cmd = "sub1 sub2 sub3 sub4 sub5"
*/
String cmd() default "";
/**
* The arguments for the command. Required arguments must be enclosed in <>'s
* <br><br>
* Example:
* <br>
* args = "&lt;arg1&gt; &lt;arg2&gt; [arg3]"
* <br>
* Argument 1 and 2 are required; the third is optional.
*/
String args() default "";
/**
* Specifies the type that an argument should be. Default is Arg.ArgString.
* <br><br>
* Example:
* <br>
* argTypes = { Arg.ArgInteger, Arg.ArgString, Arg.ArgPlayer }
* <ul>
* <li>Argument 1 <i>must</i> be an integer.</li>
* <li>Argument 2 <i>must</i> be a string(anything).</li>
* <li>Argument 3 <i>must</i> be an online player.</li>
* </ul>
*/
Class<? extends AbstractArg<?>>[] argTypes() default { };
/**
* The text to show next to a command when a user does /cmd help.
*/
String help() default "Default help thingy... :(";
/**
* The text to show next to a command when a user does /cmd help &lt;command&gt;.
* <br><br>
* Use to give more information about the command.
*/
String longhelp() default "";
/**
* Should the command be shown in help at all?
*/
boolean showInHelp() default true;
/**
* Specifies if the command should be restricted to CONSOLE, OP, or PLAYERS. Otherwise, ALL.
*/
CommandOnly only() default CommandOnly.ALL;
/**
* The permission node to use.
* <br><br>
* Example:
* <br>
* new CommandManager(plugin, "Test", "testnode", "test");
* <br>
* permission = "edit"
* <br>
* This setup would require a player to have testnode.edit to use the command.
*/
String permission() default "";
}
/**
* Used to define who is allowed to use a command.
*/
public static enum CommandOnly {
/**
* Only players can use the command.
*/
PLAYER,
/**
* Only op players can use the command. Can be overridden by a matched permission.
*/
OP,
/**
* Only the console can use the command.
*/
CONSOLE,
/**
* Anyone can use the command, given their permissions match.
*/
ALL
}
/**
* This can be used to immediately throw an error without returning a <code>CommandFinished</code>.
* It'll display the specified error.
*/
public static class CommandException extends Exception {
private static final long serialVersionUID = 1L;
public CommandException(String message) {
super(message);
}
}
/**
* A set of command errors.
*/
public static enum CommandFinished {
/** Finished correctly */
DONE(false, "Done"),
/** Command does not exist */
COMMAND(true, "Command does not exist. Use /%s for help."),
/** Command does not exist */
BADCOMMAND(true, "Bad command usage: /%s "),
/** Console not allowed to use */
NOCONSOLE(true, "This command cannot be run from the console."),
/** Player not allowed to use */
NOPLAYER(true, "This command cannot be run by players."),
/** Player does not exist */
EXISTPLAYER(true, "That player does not exist."),
/** Incorrect permissions */
PERMISSION(true, "Insufficient permissions."),
HOLDBLOCK(true, "You must be holding a block."),
HOLDITEM(true, "You must be holding an item."),
LONGSTRING(true, "String cannot be longer than %s!"),
/** Custom error */
CUSTOM(true, "%s"),
EXCEPTION(true, "An exception occured. Please contact a member of staff and tell them!");
private boolean shouldPrint;
private String errorString;
private String extraString;
CommandFinished(boolean par1ShouldPrint, String par1Error) {
shouldPrint = par1ShouldPrint;
errorString = par1Error;
}
public boolean shouldPrint() {
return shouldPrint;
}
public String getErrorString() {
if(extraString != null)
return errorString.replace("%s", extraString);
else
return errorString;
}
public CommandFinished replace(String theString) {
extraString = theString;
return this;
}
}
/*
* Arguments
*/
public static interface IArgParse<T> {
T parseArg(String arg);
List<String> getPredictions();
String getFailure();
}
public static abstract class AbstractArg<T> implements IArgParse<T> {
public AbstractArg() { }
}
public static class Arg {
public static class ArgArray extends AbstractArg<List<String>> {
public List<String> parseArg(String arg) {
List<String> list = new ArrayList<String>();
for(String str : arg.split(","))
list.add(str);
return list;
}
public String getFailure() {
return "Argument failure.";
}
public List<String> getPredictions() { return null; }
}
public static class ArgBoolean extends AbstractArg<Boolean> {
public Boolean parseArg(String arg) {
if(arg.equalsIgnoreCase("true") || arg.equalsIgnoreCase("yes") || arg.equalsIgnoreCase("on") || arg.equalsIgnoreCase("1"))
return true;
if(arg.equalsIgnoreCase("false") || arg.equalsIgnoreCase("no") || arg.equalsIgnoreCase("off") || arg.equalsIgnoreCase("0"))
return false;
return null;
}
public String getFailure() {
return "Argument not a valid boolean.";
}
public List<String> getPredictions() { return Arrays.asList(new String[] { "true", "false" }); }
}
public static class ArgByte extends AbstractArg<Byte> {
public Byte parseArg(String arg) {
return Byte.valueOf(arg);
}
public String getFailure() {
return "Argument not a valid byte.";
}
public List<String> getPredictions() { return Arrays.asList(new String[] { "0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15" }); }
}
public static class ArgDouble extends AbstractArg<Double> {
public Double parseArg(String arg) {
return Double.valueOf(arg);
}
public String getFailure() {
return "Argument not a real number.";
}
public List<String> getPredictions() { return null; }
}
public static class ArgFloat extends AbstractArg<Float> {
public Float parseArg(String arg) {
return Float.valueOf(arg);
}
public String getFailure() {
return "Argument not a floating point number.";
}
public List<String> getPredictions() { return null; }
}
public static class ArgInteger extends AbstractArg<Integer> {
public Integer parseArg(String arg) {
return Integer.valueOf(arg);
}
public String getFailure() {
return "Argument not an integer.";
}
public List<String> getPredictions() { return null; }
}
public static class ArgPlayer extends AbstractArg<OfflinePlayer> {
public Player parseArg(String arg) {
return Bukkit.getPlayer(arg);
}
public String getFailure() {
return CommandFinished.EXISTPLAYER.getErrorString();
}
public List<String> getPredictions() {
List<String> players = new ArrayList<String>();
for(Player p : Bukkit.getOnlinePlayers())
players.add(p.getName());
return players;
}
}
public static class ArgOfflinePlayer extends AbstractArg<OfflinePlayer> {
@SuppressWarnings("deprecation")
public OfflinePlayer parseArg(String arg) {
return Bukkit.getOfflinePlayer(arg);
}
public String getFailure() {
return CommandFinished.EXISTPLAYER.getErrorString();
}
public List<String> getPredictions() {
List<String> players = new ArrayList<String>();
for(Player p : Bukkit.getOnlinePlayers())
players.add(p.getName());
return players;
}
}
public static class ArgString extends AbstractArg<String> {
public String parseArg(String arg) {
return arg;
}
public String getFailure() {
return "Could not parse string.";
}
public List<String> getPredictions() { return null; }
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment