diff --git a/Essentials/src/main/java/com/earth2me/essentials/commands/Commandessentials.java b/Essentials/src/main/java/com/earth2me/essentials/commands/Commandessentials.java index 6fa2873afa2..70e3c62c63e 100644 --- a/Essentials/src/main/java/com/earth2me/essentials/commands/Commandessentials.java +++ b/Essentials/src/main/java/com/earth2me/essentials/commands/Commandessentials.java @@ -15,7 +15,6 @@ import com.earth2me.essentials.utils.PasteUtil; import com.earth2me.essentials.utils.VersionUtil; import com.google.common.collect.ImmutableMap; -import com.google.common.collect.Lists; import com.google.gson.JsonArray; import com.google.gson.JsonNull; import com.google.gson.JsonObject; @@ -26,7 +25,6 @@ import org.bukkit.Material; import org.bukkit.Server; import org.bukkit.Sound; -import org.bukkit.World; import org.bukkit.command.Command; import org.bukkit.entity.Player; import org.bukkit.inventory.ItemStack; @@ -57,7 +55,6 @@ import java.util.concurrent.CompletableFuture; import java.util.function.Supplier; import java.util.logging.Level; -import java.util.stream.Collectors; import static com.earth2me.essentials.I18n.tl; @@ -113,70 +110,91 @@ public class Commandessentials extends EssentialsCommand { ); private transient TuneRunnable currentTune = null; + private final EssentialsCommandNode.Root tree = EssentialsCommandNode.root(root -> { + // Info commands + root.literal("debug", ctx -> ctx.execute(this::runDebug), "verbose"); + root.literal("version", ctx -> ctx.execute(this::runVersion), "ver"); + root.literal("commands", ctx -> ctx.execute(this::runCommands), "cmd"); + root.literal("dump", ctx -> ctx.execute(this::runDump)); + + // Config commands + root.literal("reload", ctx -> ctx.execute(this::runReload)); + root.literal("locale", locale -> { + // TODO + locale.execute(ctx -> ctx.sender().sendMessage("Not yet implemented"), Arrays.asList("create", "export", "getraw")); + }, "lang"); + + // Data commands + root.literal("reset", ctx -> ctx.execute(this::runReset)); + root.literal("cleanup", ctx -> ctx.execute(this::runCleanup)); + root.literal("homes", ctx -> ctx.execute(this::runHomes)); + root.literal("usermap", usermap -> { + // TODO: split these out from #runUsermap + usermap.literal("full", ctx -> ctx.execute(TODO -> {})); + usermap.literal("purge", ctx -> ctx.execute(TODO -> {})); + usermap.literal("lookup", ctx -> ctx.execute(TODO -> {})); + }); + + // Internal debugging and #EasterEgg + root.literal("itemtest", ctx -> ctx.execute(this::runItemTest)); + // TODO: hide from tab-complete + root.literal("moo", ctx -> ctx.execute(this::runMoo)); // todo: moo moo + root.literal("nyan", ctx -> ctx.execute(this::runNya), "nya"); + + // TODO: missing tab completions + /* + switch (args[0]) { + case "moo": + if (args.length == 2) { + return Lists.newArrayList("moo"); + } + break; + case "reset": + if (args.length == 2) { + return getPlayers(server, sender); + } + break; + case "cleanup": + if (args.length == 2) { + return COMMON_DURATIONS; + } else if (args.length == 3 || args.length == 4) { + return Lists.newArrayList("-1", "0"); + } + break; + case "homes": + if (args.length == 2) { + return Lists.newArrayList("fix", "delete"); + } else if (args.length == 3 && args[1].equalsIgnoreCase("delete")) { + return server.getWorlds().stream().map(World::getName).collect(Collectors.toList()); + } + break; + case "dump": + final List list = Lists.newArrayList("config", "kits", "log", "discord", "worth", "tpr", "spawns", "commands", "all"); + for (String arg : args) { + if (arg.equals("*") || arg.equalsIgnoreCase("all")) { + list.clear(); + return list; + } + list.remove(arg.toLowerCase(Locale.ENGLISH)); + } + return list; + } + + return Collections.emptyList(); + + */ + }); + public Commandessentials() { super("essentials"); } @Override public void run(final Server server, final CommandSource sender, final String commandLabel, final String[] args) throws Exception { - if (args.length == 0) { - showUsage(sender); - } - - switch (args[0]) { - // Info commands - case "debug": - case "verbose": - runDebug(server, sender, commandLabel, args); - break; - case "ver": - case "version": - runVersion(server, sender, commandLabel, args); - break; - case "cmd": - case "commands": - runCommands(server, sender, commandLabel, args); - break; - case "dump": - runDump(server, sender, commandLabel, args); - break; - - // Data commands - case "reload": - runReload(server, sender, commandLabel, args); - break; - case "reset": - runReset(server, sender, commandLabel, args); - break; - case "cleanup": - runCleanup(server, sender, commandLabel, args); - break; - case "homes": - runHomes(server, sender, commandLabel, args); - break; - case "usermap": - runUserMap(sender, args); - break; - - case "itemtest": - runItemTest(server, sender, commandLabel, args); - break; - - // "#EasterEgg" - case "nya": - case "nyan": - runNya(server, sender, commandLabel, args); - break; - case "moo": - runMoo(server, sender, commandLabel, args); - break; - default: - showUsage(sender); - break; - } + tree.run(server, sender, commandLabel, args); } - public void runItemTest(Server server, CommandSource sender, String commandLabel, String[] args) { + public void runItemTest(final Server server, final CommandSource sender, final String commandLabel, final String[] args) { if (!sender.isAuthorized("essentials.itemtest", ess) || args.length < 2 || !sender.isPlayer()) { return; } @@ -227,6 +245,7 @@ public void runItemTest(Server server, CommandSource sender, String commandLabel } // Displays the command's usage. + // todo: remove private void showUsage(final CommandSource sender) throws Exception { throw new NotEnoughArgumentsException(); } @@ -474,9 +493,9 @@ private void runReset(final Server server, final CommandSource sender, final Str } // Toggles debug mode. - private void runDebug(final Server server, final CommandSource sender, final String commandLabel, final String[] args) throws Exception { + private void runDebug(final EssentialsCommandNode.WalkContext context) { ess.getSettings().setDebug(!ess.getSettings().isDebug()); - sender.sendMessage("Essentials " + ess.getDescription().getVersion() + " debug mode " + (ess.getSettings().isDebug() ? "enabled" : "disabled")); + context.sender().sendMessage("Essentials " + ess.getDescription().getVersion() + " debug mode " + (ess.getSettings().isDebug() ? "enabled" : "disabled")); } // Reloads all reloadable configs. @@ -820,60 +839,12 @@ private void runVersion(final Server server, final CommandSource sender, final S @Override protected List getTabCompleteOptions(final Server server, final CommandSource sender, final String commandLabel, final String[] args) { - if (args.length == 1) { - final List options = Lists.newArrayList(); - options.add("reload"); - options.add("version"); - options.add("dump"); - options.add("commands"); - options.add("debug"); - options.add("reset"); - options.add("cleanup"); - options.add("homes"); - //options.add("uuidconvert"); - //options.add("nya"); - //options.add("moo"); - return options; - } - - switch (args[0]) { - case "moo": - if (args.length == 2) { - return Lists.newArrayList("moo"); - } - break; - case "reset": - if (args.length == 2) { - return getPlayers(server, sender); - } - break; - case "cleanup": - if (args.length == 2) { - return COMMON_DURATIONS; - } else if (args.length == 3 || args.length == 4) { - return Lists.newArrayList("-1", "0"); - } - break; - case "homes": - if (args.length == 2) { - return Lists.newArrayList("fix", "delete"); - } else if (args.length == 3 && args[1].equalsIgnoreCase("delete")) { - return server.getWorlds().stream().map(World::getName).collect(Collectors.toList()); - } - break; - case "dump": - final List list = Lists.newArrayList("config", "kits", "log", "discord", "worth", "tpr", "spawns", "commands", "all"); - for (String arg : args) { - if (arg.equals("*") || arg.equalsIgnoreCase("all")) { - list.clear(); - return list; - } - list.remove(arg.toLowerCase(Locale.ENGLISH)); - } - return list; + try { + return tree.tabComplete(server, sender, commandLabel, args); + } catch (Exception e) { + // TODO: ??? + throw new RuntimeException(e); } - - return Collections.emptyList(); } private static class TuneRunnable extends BukkitRunnable { diff --git a/Essentials/src/main/java/com/earth2me/essentials/commands/EssentialsCommandNode.java b/Essentials/src/main/java/com/earth2me/essentials/commands/EssentialsCommandNode.java new file mode 100644 index 00000000000..25fb0779eaf --- /dev/null +++ b/Essentials/src/main/java/com/earth2me/essentials/commands/EssentialsCommandNode.java @@ -0,0 +1,246 @@ +package com.earth2me.essentials.commands; + +import com.earth2me.essentials.CommandSource; +import org.bukkit.Server; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; + +public abstract class EssentialsCommandNode { + private final ArrayList> childNodes = new ArrayList<>(); + + protected EssentialsCommandNode(final Initializer initializer) { + initializer.init(new BuildContext<>(this)); + } + + protected void run(WalkContext context) throws Exception { + // TODO: consider moving walk logic into non-terminal node subclass + for (EssentialsCommandNode node : childNodes) { + if (node.matches(context)) { + node.run(context); + return; + } + } + + // we only want exact matches, so throw an error + throw new NotEnoughArgumentsException(); + } + + protected List tabComplete(WalkContext context) throws Exception { + // TODO: consider moving walk logic into non-terminal node subclass + + // try and full match first + for (EssentialsCommandNode node : childNodes) { + if (node.matches(context)) { + return node.tabComplete(context); + } + } + + // try for partial matches + final ArrayList parts = new ArrayList<>(); + if (context.args.length == 1) { + for (EssentialsCommandNode node : childNodes) { + if (node instanceof Literal) { + final Literal literal = (Literal) node; + if (literal.name().startsWith(context.args[0])) { + parts.add(literal.name()); + } + } + } + } + + return parts; + } + + protected List> getChildNodes() { + return Collections.unmodifiableList(childNodes); + } + + public abstract boolean matches(final WalkContext context); + + public static Root root(final Initializer initializer) { + return new Root<>(initializer); + } + + public interface Initializer { + void init(BuildContext node); + } + + public static class BuildContext { + private final EssentialsCommandNode node; + + protected BuildContext(EssentialsCommandNode node) { + this.node = node; + } + + public void literal(final String name, final Initializer initializer, final String... aliases) { + node.childNodes.add(new Literal<>(name, aliases, initializer)); + } + + public void execute(final RunHandler runHandler) { + this.execute(runHandler, ctx -> Collections.emptyList()); + } + + public void execute(final LegacyRunHandler runHandler) { + this.execute(runHandler, ctx -> Collections.emptyList()); + } + + public void execute(final RunHandler runHandler, final List tabValues) { + this.execute(runHandler, ctx -> tabValues); + } + + public void execute(final RunHandler runHandler, final TabHandler tabHandler) { + node.childNodes.add(new Execute<>(runHandler, tabHandler)); + } + } + + public static class WalkContext { + private final Server server; + private final T sender; + private final String label; + private final String[] args; + + protected WalkContext(Server server, T sender, String label, String[] args) { + this.server = server; + this.sender = sender; + this.label = label; + this.args = args; + } + + protected WalkContext next() { + String[] nextArgs = {}; + if (this.args.length > 1) { + nextArgs = Arrays.copyOfRange(this.args, 1, this.args.length); + } + return new WalkContext<>(this.server, this.sender, this.label, nextArgs); + } + + public Server server() { + return server; + } + + public T sender() { + return sender; + } + + public String label() { + return label; + } + + public String[] args() { + return args; + } + } + + public static class Root extends EssentialsCommandNode { + protected Root(Initializer initializer) { + super(initializer); + if (getChildNodes().isEmpty()) { + throw new IllegalStateException("Root nodes must be initialised with at least one child"); + } + } + + @Override + public boolean matches(WalkContext context) { + throw new IllegalStateException("Root commands should not be placed in the tree"); + } + + public void run(Server server, T sender, String commandLabel, String[] args) throws Exception { + run(new WalkContext<>(server, sender, commandLabel, args)); + } + + public List tabComplete(Server server, T sender, String commandLabel, String[] args) throws Exception { + return tabComplete(new WalkContext<>(server, sender, commandLabel, args)); + } + } + + public static class Literal extends EssentialsCommandNode { + private final String name; + private final HashSet aliases; + + protected Literal(String name, String[] aliases, Initializer initializer) { + super(initializer); + if (getChildNodes().isEmpty()) { + throw new IllegalStateException("Literal nodes must be initialised with at least one child (node name: " + name + ")"); + } + + this.name = name; + this.aliases = new HashSet<>(); + this.aliases.add(name.toLowerCase(Locale.ROOT)); + for (final String alias : aliases) { + this.aliases.add(alias.toLowerCase(Locale.ROOT)); + } + } + + public String name() { + return name; + } + + public boolean matches(WalkContext context) { + return context.args.length > 0 && aliases.contains(context.args[0].toLowerCase(Locale.ROOT)); + } + + @Override + protected void run(WalkContext context) throws Exception { + // consume argument + context = context.next(); + super.run(context); + } + + @Override + protected List tabComplete(WalkContext context) throws Exception { + // consume argument + context = context.next(); + return super.tabComplete(context); + } + } + + public static class Execute extends EssentialsCommandNode { + private final RunHandler runHandler; + private final TabHandler tabHandler; + + protected Execute(RunHandler runHandler, TabHandler tabHandler) { + super(ctx -> {}); + this.runHandler = runHandler; + this.tabHandler = tabHandler; + } + + @Override + public boolean matches(WalkContext context) { + return true; + } + + @Override + protected void run(WalkContext context) throws Exception { + runHandler.handle(context); + } + + @Override + protected List tabComplete(WalkContext context) throws Exception { + return tabHandler.handle(context); + } + } + + public interface RunHandler { + void handle(WalkContext ctx) throws Exception; + } + + // todo: not sure whether to keep or to rewrite usages + @Deprecated + public interface LegacyRunHandler extends RunHandler { + @Override + default void handle(WalkContext ctx) throws Exception { + handle(ctx.server, ctx.sender, ctx.label, ctx.args); + } + + void handle(Server server, T sender, String label, String[] args) throws Exception; + } + + public interface TabHandler { + List handle(WalkContext ctx) throws Exception; + } +} diff --git a/Essentials/src/test/java/com/earth2me/essentials/commands/EssentialsCommandNodeTest.java b/Essentials/src/test/java/com/earth2me/essentials/commands/EssentialsCommandNodeTest.java new file mode 100644 index 00000000000..b5c68e6f558 --- /dev/null +++ b/Essentials/src/test/java/com/earth2me/essentials/commands/EssentialsCommandNodeTest.java @@ -0,0 +1,89 @@ +package com.earth2me.essentials.commands; + +import com.earth2me.essentials.CommandSource; +import com.earth2me.essentials.FakeServer; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.InOrder; + +import java.util.Arrays; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; + +class EssentialsCommandNodeTest { + private FakeServer fakeServer; + private CommandSource playerSource; + private CommandSource consoleSource; + + @BeforeEach + void setup() { + fakeServer = FakeServer.getServer(); + + playerSource = mock(CommandSource.class); + consoleSource = mock(CommandSource.class); + } + + EssentialsCommandNode.Root buildCommonTree() { + return EssentialsCommandNode.root(root -> { + root.literal("hello", hello -> hello.execute(ctx -> { + if (ctx.args().length < 1) { + ctx.sender().sendMessage("hello to who?"); + } else if (ctx.args().length < 2) { + ctx.sender().sendMessage("hi there " + ctx.args()[0]); + } else { + ctx.sender().sendMessage("woah hi " + String.join(" and ", ctx.args())); + } + System.out.println(Arrays.toString(ctx.args())); + })); + root.literal("bye", bye -> { + bye.literal("forever just kidding", bye1 -> bye1.execute(ctx -> { + throw new RuntimeException("this shouldn't happen"); + })); + bye.literal("forever", bye2 -> bye2.execute(ctx -> ctx.sender().sendMessage(":(("))); + + bye.execute(ctx -> { + if (ctx.sender().isPlayer()) { + ctx.sender().sendMessage(":("); + } else { + ctx.sender().sendMessage("wait you can't leave"); + } + }); + }, "farewell", "tschuss"); + }); + } + + @Test + void testBuild() { + assertThrows(RuntimeException.class, () -> EssentialsCommandNode.root(root -> {}), "empty root"); + assertThrows(RuntimeException.class, () -> EssentialsCommandNode.root(root -> { + root.literal("potato", potato -> {}); + }), "empty literal"); + + assertDoesNotThrow(this::buildCommonTree, "build complete tree"); + } + + @Test + void testEval() { + final EssentialsCommandNode.Root rootNode = buildCommonTree(); + + assertThrows(NotEnoughArgumentsException.class, () -> rootNode.run(fakeServer, playerSource, "test", new String[]{""}), "wrongly parsed empty arg"); + assertThrows(NotEnoughArgumentsException.class, () -> rootNode.run(fakeServer, playerSource, "test", new String[]{"wilkommen"}), "wrongly parsed unknown literal"); // wrongly parsed German + + assertDoesNotThrow(() -> rootNode.run(fakeServer, playerSource, "test", new String[]{"hello"}), "parsing first level no-arg command"); + assertDoesNotThrow(() -> rootNode.run(fakeServer, playerSource, "test", new String[]{"hello", "world"}), "parsing first level 1 arg command"); + assertDoesNotThrow(() -> rootNode.run(fakeServer, playerSource, "test", new String[]{"hello", "jroy", "pop", "lax", "evident"}), "parsing first level multi-arg command"); + assertDoesNotThrow(() -> rootNode.run(fakeServer, playerSource, "test", new String[]{"bye", "legacy", "code"})); + assertDoesNotThrow(() -> rootNode.run(fakeServer, consoleSource, "test", new String[]{"fAREWELL", "player", "data"}), "parsing with literal alias"); + assertDoesNotThrow(() -> rootNode.run(fakeServer, consoleSource, "test", new String[]{"bye", "forever", "just", "kidding"})); + + final InOrder ordered = inOrder(playerSource, consoleSource); + ordered.verify(playerSource).sendMessage("hello to who?"); + ordered.verify(playerSource).sendMessage("hi there world"); + ordered.verify(playerSource).sendMessage("woah hi jroy and pop and lax and evident"); + ordered.verify(consoleSource).sendMessage("wait you can't leave"); + ordered.verify(consoleSource).sendMessage(":(("); + } +}