From c22e26148a544ddc308a061097e1d56fe08de25d Mon Sep 17 00:00:00 2001 From: Traqueur_ Date: Mon, 7 Jul 2025 20:24:46 +0200 Subject: [PATCH 1/5] feat(commands): add command tree to remove complexity --- .../traqueur/commands/api/CommandInvoker.java | 188 ------------------ .../traqueur/commands/api/CommandManager.java | 27 ++- .../api/{ => arguments}/Arguments.java | 5 +- .../commands/api/{ => models}/Command.java | 11 +- .../commands/api/models/CommandInvoker.java | 136 +++++++++++++ .../api/{ => models}/CommandPlatform.java | 4 +- .../api/models/collections/CommandTree.java | 147 ++++++++++++++ .../commands/api/CommandManagerTest.java | 18 +- .../api/{ => arguments}/ArgumentsTest.java | 2 +- .../api/{ => models}/CommandInvokerTest.java | 38 ++-- .../api/{ => models}/CommandTest.java | 8 +- .../models/collections/CommandTreeTest.java | 127 ++++++++++++ .../traqueur/testplugin/Sub2TestCommand.java | 2 +- .../traqueur/testplugin/SubTestCommand.java | 2 +- .../fr/traqueur/testplugin/TestCommand.java | 2 +- .../fr/traqueur/commands/spigot/Command.java | 4 +- .../commands/spigot/SpigotPlatform.java | 57 +++--- .../spigot/SpigotIntegrationTest.java | 8 +- .../velocityTestPlugin/Sub2TestCommand.java | 2 +- .../velocityTestPlugin/SubTestCommand.java | 2 +- .../velocityTestPlugin/TestCommand.java | 2 +- .../traqueur/commands/velocity/Command.java | 4 +- .../commands/velocity/VelocityPlatform.java | 28 +-- 23 files changed, 526 insertions(+), 298 deletions(-) delete mode 100644 core/src/main/java/fr/traqueur/commands/api/CommandInvoker.java rename core/src/main/java/fr/traqueur/commands/api/{ => arguments}/Arguments.java (98%) rename core/src/main/java/fr/traqueur/commands/api/{ => models}/Command.java (97%) create mode 100644 core/src/main/java/fr/traqueur/commands/api/models/CommandInvoker.java rename core/src/main/java/fr/traqueur/commands/api/{ => models}/CommandPlatform.java (95%) create mode 100644 core/src/main/java/fr/traqueur/commands/api/models/collections/CommandTree.java rename core/src/test/java/fr/traqueur/commands/api/{ => arguments}/ArgumentsTest.java (98%) rename core/src/test/java/fr/traqueur/commands/api/{ => models}/CommandInvokerTest.java (84%) rename core/src/test/java/fr/traqueur/commands/api/{ => models}/CommandTest.java (96%) create mode 100644 core/src/test/java/fr/traqueur/commands/api/models/collections/CommandTreeTest.java diff --git a/core/src/main/java/fr/traqueur/commands/api/CommandInvoker.java b/core/src/main/java/fr/traqueur/commands/api/CommandInvoker.java deleted file mode 100644 index b204684..0000000 --- a/core/src/main/java/fr/traqueur/commands/api/CommandInvoker.java +++ /dev/null @@ -1,188 +0,0 @@ -package fr.traqueur.commands.api; - -import fr.traqueur.commands.api.arguments.TabCompleter; -import fr.traqueur.commands.api.exceptions.ArgumentIncorrectException; -import fr.traqueur.commands.api.exceptions.TypeArgumentNotExistException; -import fr.traqueur.commands.api.requirements.Requirement; - -import java.util.Arrays; -import java.util.Collections; -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; - -/** - * CommandInvoker is responsible for invoking and suggesting commands. - * It performs lookup, permission and requirement checks, usage display, parsing, and execution. - * - * @param plugin type - * @param sender type - */ -public class CommandInvoker { - - private static class CommandEntry { - String label; - String[] args; - - CommandEntry(String label, String[] args) { - this.label = label; - this.args = args; - } - } - - /** - * The CommandManager instance that manages commands and their configurations. - */ - private final CommandManager manager; - - /** - * Constructs a CommandInvoker with the specified CommandManager. - * - * @param manager the CommandManager instance to use - */ - public CommandInvoker(CommandManager manager) { - this.manager = manager; - } - - /** - * Invokes a command based on the provided source, base label, and raw arguments. - * It checks for command existence, permissions, requirements, usage, and executes the command if valid. - * - * @param source the sender of the command - * @param base the base label of the command - * @param rawArgs the raw arguments passed to the command - * @return true if the command was successfully invoked, false otherwise - */ - public boolean invoke(S source, String base, String[] rawArgs) { - CommandEntry entry = findCommand(base, rawArgs); - if (entry == null) return false; - String label = entry.label; - Command command = manager.getCommands().get(label); - String[] modArgs = entry.args; - - if (checkInGame(source, command) || checkPermission(source, command) || checkRequirements(source, command)) { - return true; - } - - if (checkUsage(source, command, label, modArgs)) { - return true; - } - - return parseAndExecute(source, command, modArgs); - } - - /** - * Suggests command completions based on the provided source, base label, and arguments. - * It checks for matching commands and applies filters based on permissions and requirements. - * - * @param source the sender of the command - * @param base the base label of the command - * @param args the arguments passed to the command - * @return a list of suggested completions - */ - public List suggest(S source, String base, String[] args) { - for (int i = args.length; i >= 0; i--) { - String label = buildLabel(base, args, i); - Map> map = manager.getCompleters().get(label); - if (map != null && map.containsKey(args.length)) { - return map.get(args.length).onCompletion(source, Collections.singletonList(buildArgsBefore(base, args))) - .stream() - .filter(opt -> allowedSuggestion(source, label, opt)) - .filter(s -> matchesPrefix(s, args[args.length - 1])) - .collect(Collectors.toList()); - } - } - return Collections.emptyList(); - } - - private CommandEntry findCommand(String base, String[] args) { - for (int i = args.length; i >= 0; i--) { - String label = buildLabel(base, args, i); - if (manager.getCommands().containsKey(label)) { - String[] mod = Arrays.copyOfRange(args, i, args.length); - return new CommandEntry(label, mod); - } - } - return null; - } - - private boolean checkInGame(S src, Command cmd) { - if (cmd.inGameOnly() && !manager.getPlatform().isPlayer(src)) { - manager.getPlatform().sendMessage(src, manager.getMessageHandler().getOnlyInGameMessage()); - return true; - } - return false; - } - - private boolean checkPermission(S src, Command cmd) { - String perm = cmd.getPermission(); - if (!perm.isEmpty() && !manager.getPlatform().hasPermission(src, perm)) { - manager.getPlatform().sendMessage(src, manager.getMessageHandler().getNoPermissionMessage()); - return true; - } - return false; - } - - private boolean checkRequirements(S src, Command cmd) { - for (Requirement r : cmd.getRequirements()) { - if (!r.check(src)) { - String msg = r.errorMessage().isEmpty() - ? manager.getMessageHandler().getRequirementMessage().replace("%requirement%", r.getClass().getSimpleName()) - : r.errorMessage(); - manager.getPlatform().sendMessage(src, msg); - return true; - } - } - return false; - } - - private boolean checkUsage(S src, Command cmd, String label, String[] modArgs) { - int min = cmd.getArgs().size(); - int max = cmd.isInfiniteArgs() ? Integer.MAX_VALUE : min + cmd.getOptinalArgs().size(); - if (modArgs.length < min || modArgs.length > max) { - String usage = cmd.getUsage().isEmpty() - ? cmd.generateDefaultUsage(manager.getPlatform(), src, label) - : cmd.getUsage(); - manager.getPlatform().sendMessage(src, usage); - return true; - } - return false; - } - - private boolean parseAndExecute(S src, Command cmd, String[] modArgs) { - try { - Arguments args = manager.parse(cmd, modArgs); - cmd.execute(src, args); - } catch (TypeArgumentNotExistException e) { - manager.getPlatform().sendMessage(src, "&cInternal error: invalid argument type"); - return false; - } catch (ArgumentIncorrectException e) { - String msg = manager.getMessageHandler().getArgNotRecognized().replace("%arg%", e.getInput()); - manager.getPlatform().sendMessage(src, msg); - } - return true; - } - - private String buildLabel(String base, String[] args, int count) { - StringBuilder sb = new StringBuilder(base.toLowerCase()); - for (int i = 0; i < count; i++) sb.append('.').append(args[i].toLowerCase()); - return sb.toString(); - } - - private String buildArgsBefore(String base, String[] args) { - return base + "." + String.join(".", Arrays.copyOf(args, args.length - 1)); - } - - private boolean matchesPrefix(String candidate, String current) { - String lower = current.toLowerCase(); - return candidate.equalsIgnoreCase(current) || candidate.toLowerCase().startsWith(lower); - } - - private boolean allowedSuggestion(S src, String label, String opt) { - String full = label + "." + opt.toLowerCase(); - Command c = manager.getCommands().get(full); - if (c == null) return true; - return c.getRequirements().stream().allMatch(r -> r.check(src)) - && (c.getPermission().isEmpty() || manager.getPlatform().hasPermission(src, c.getPermission())); - } -} diff --git a/core/src/main/java/fr/traqueur/commands/api/CommandManager.java b/core/src/main/java/fr/traqueur/commands/api/CommandManager.java index c54f21a..4ffd277 100644 --- a/core/src/main/java/fr/traqueur/commands/api/CommandManager.java +++ b/core/src/main/java/fr/traqueur/commands/api/CommandManager.java @@ -2,11 +2,16 @@ import fr.traqueur.commands.api.arguments.Argument; import fr.traqueur.commands.api.arguments.ArgumentConverter; +import fr.traqueur.commands.api.arguments.Arguments; import fr.traqueur.commands.api.arguments.TabCompleter; import fr.traqueur.commands.api.exceptions.ArgumentIncorrectException; import fr.traqueur.commands.api.exceptions.TypeArgumentNotExistException; import fr.traqueur.commands.api.logging.Logger; import fr.traqueur.commands.api.logging.MessageHandler; +import fr.traqueur.commands.api.models.Command; +import fr.traqueur.commands.api.models.CommandInvoker; +import fr.traqueur.commands.api.models.CommandPlatform; +import fr.traqueur.commands.api.models.collections.CommandTree; import fr.traqueur.commands.api.updater.Updater; import fr.traqueur.commands.impl.arguments.BooleanArgument; import fr.traqueur.commands.impl.arguments.DoubleArgument; @@ -44,7 +49,7 @@ public abstract class CommandManager { /** * The commands registered in the command manager. */ - private final Map> commands; + private final CommandTree commands; /** * The argument converters registered in the command manager. @@ -86,7 +91,7 @@ public CommandManager(CommandPlatform platform) { this.messageHandler = new InternalMessageHandler(); this.logger = new InternalLogger(platform.getLogger()); this.debug = false; - this.commands = new HashMap<>(); + this.commands = new CommandTree<>(); this.typeConverters = new HashMap<>(); this.completers = new HashMap<>(); this.invoker = new CommandInvoker<>(this); @@ -165,10 +170,14 @@ public void unregisterCommand(String label) { * @param subcommands If the subcommands must be unregistered. */ public void unregisterCommand(String label, boolean subcommands) { - if(this.commands.get(label) == null) { - throw new IllegalArgumentException("The command " + label + " does not exist."); + String[] rawArgs = label.split("\\."); + Optional> commandOptional = this.commands.findNode(rawArgs) + .flatMap(result -> result.node.getCommand()); + + if (!commandOptional.isPresent()) { + throw new IllegalArgumentException("Command with label '" + label + "' does not exist."); } - this.unregisterCommand(this.commands.get(label), subcommands); + this.unregisterCommand(commandOptional.get(), subcommands); } /** @@ -252,7 +261,7 @@ public Arguments parse(Command command, String[] args) throws TypeArgumentN * Get the commands of the command manager. * @return The commands of the command manager. */ - public Map> getCommands() { + public CommandTree getCommands() { return commands; } @@ -327,7 +336,7 @@ private void unregisterSubCommands(String parentLabel, List> subcom */ private void removeCommand(String label, boolean subcommand) { this.platform.removeCommand(label, subcommand); - this.commands.remove(label); + this.commands.removeCommand(label, subcommand); this.completers.remove(label); } @@ -351,9 +360,7 @@ private void addCommand(Command command, String label) throws TypeArgumentN } command.setManager(this); - - commands.put(label.toLowerCase(), command); - + commands.addCommand(label, command); this.platform.addCommand(command, label); this.addCompletionsForLabel(labelParts); diff --git a/core/src/main/java/fr/traqueur/commands/api/Arguments.java b/core/src/main/java/fr/traqueur/commands/api/arguments/Arguments.java similarity index 98% rename from core/src/main/java/fr/traqueur/commands/api/Arguments.java rename to core/src/main/java/fr/traqueur/commands/api/arguments/Arguments.java index 5226f18..6a34bdf 100644 --- a/core/src/main/java/fr/traqueur/commands/api/Arguments.java +++ b/core/src/main/java/fr/traqueur/commands/api/arguments/Arguments.java @@ -1,6 +1,5 @@ -package fr.traqueur.commands.api; +package fr.traqueur.commands.api.arguments; -import fr.traqueur.commands.api.arguments.ArgumentValue; import fr.traqueur.commands.api.exceptions.ArgumentNotExistException; import fr.traqueur.commands.api.exceptions.NoGoodTypeArgumentException; import fr.traqueur.commands.api.logging.Logger; @@ -371,7 +370,7 @@ public Optional getOptional(String argument) { * @param type The type of the argument. * @param object The object of the argument. */ - protected void add(String key, Class type, Object object) { + public void add(String key, Class type, Object object) { ArgumentValue argumentValue = new ArgumentValue(type, object); this.arguments.put(key, argumentValue); } diff --git a/core/src/main/java/fr/traqueur/commands/api/Command.java b/core/src/main/java/fr/traqueur/commands/api/models/Command.java similarity index 97% rename from core/src/main/java/fr/traqueur/commands/api/Command.java rename to core/src/main/java/fr/traqueur/commands/api/models/Command.java index f8739db..2349145 100644 --- a/core/src/main/java/fr/traqueur/commands/api/Command.java +++ b/core/src/main/java/fr/traqueur/commands/api/models/Command.java @@ -1,5 +1,7 @@ -package fr.traqueur.commands.api; +package fr.traqueur.commands.api.models; +import fr.traqueur.commands.api.arguments.Arguments; +import fr.traqueur.commands.api.CommandManager; import fr.traqueur.commands.api.arguments.Argument; import fr.traqueur.commands.api.arguments.TabCompleter; import fr.traqueur.commands.api.exceptions.ArgsWithInfiniteArgumentException; @@ -110,7 +112,7 @@ public Command(T plugin, String name) { * This method is called to set the manager of the command. * @param manager The manager of the command. */ - protected void setManager(CommandManager manager) { + public void setManager(CommandManager manager) { this.manager = manager; } @@ -176,6 +178,11 @@ public final String getUsage() { * @return The aliases of the command. */ public final List getAliases() { + List aliases = new ArrayList<>(); + aliases.add(name); + if (!this.aliases.isEmpty()) { + aliases.addAll(this.aliases); + } return aliases; } diff --git a/core/src/main/java/fr/traqueur/commands/api/models/CommandInvoker.java b/core/src/main/java/fr/traqueur/commands/api/models/CommandInvoker.java new file mode 100644 index 0000000..9d2577d --- /dev/null +++ b/core/src/main/java/fr/traqueur/commands/api/models/CommandInvoker.java @@ -0,0 +1,136 @@ +package fr.traqueur.commands.api.models; + +import fr.traqueur.commands.api.CommandManager; +import fr.traqueur.commands.api.arguments.Arguments; +import fr.traqueur.commands.api.arguments.TabCompleter; +import fr.traqueur.commands.api.exceptions.ArgumentIncorrectException; +import fr.traqueur.commands.api.exceptions.TypeArgumentNotExistException; +import fr.traqueur.commands.api.logging.MessageHandler; +import fr.traqueur.commands.api.models.collections.CommandTree; +import fr.traqueur.commands.api.models.collections.CommandTree.MatchResult; +import fr.traqueur.commands.api.requirements.Requirement; + +import java.util.*; +import java.util.stream.Collectors; + +/** + * CommandInvoker is responsible for invoking and suggesting commands. + * It performs lookup, permission and requirement checks, usage display, parsing, and execution. + * + * @param plugin type + * @param sender type + */ +public class CommandInvoker { + + private final CommandManager manager; + + public CommandInvoker(CommandManager manager) { + this.manager = manager; + } + + /** + * Invokes a command based on the provided source, base label, and raw arguments. + * @return true if a command handler was executed or a message sent; false if command not found + */ + public boolean invoke(S source, String base, String[] rawArgs) { + // find matching node + Optional> found = manager.getCommands().findNode(base, rawArgs); + if (!found.isPresent()) return false; + MatchResult result = found.get(); + CommandTree.CommandNode node = result.node; + Optional> cmdOpt = node.getCommand(); + if (!cmdOpt.isPresent()) return false; + Command command = cmdOpt.get(); + String label = node.getFullLabel() != null ? node.getFullLabel() : base; + String[] args = result.args; + + // in-game check + if (command.inGameOnly() && !manager.getPlatform().isPlayer(source)) { + manager.getPlatform().sendMessage(source, manager.getMessageHandler().getOnlyInGameMessage()); + return true; + } + // permission check + String perm = command.getPermission(); + if (!perm.isEmpty() && !manager.getPlatform().hasPermission(source, perm)) { + manager.getPlatform().sendMessage(source, manager.getMessageHandler().getNoPermissionMessage()); + return true; + } + // requirements + for (Requirement req : command.getRequirements()) { + if (!req.check(source)) { + String msg = req.errorMessage().isEmpty() + ? manager.getMessageHandler().getRequirementMessage().replace("%requirement%", req.getClass().getSimpleName()) + : req.errorMessage(); + manager.getPlatform().sendMessage(source, msg); + return true; + } + } + // usage check + int min = command.getArgs().size(); + int max = command.isInfiniteArgs() ? Integer.MAX_VALUE : min + command.getOptinalArgs().size(); + if (args.length < min || args.length > max) { + String usage = command.getUsage().isEmpty() + ? command.generateDefaultUsage(manager.getPlatform(), source, label) + : command.getUsage(); + manager.getPlatform().sendMessage(source, usage); + return true; + } + // parse and execute + try { + Arguments parsed = manager.parse(command, args); + command.execute(source, parsed); + } catch (TypeArgumentNotExistException e) { + manager.getPlatform().sendMessage(source, "&cInternal error: invalid argument type"); + return false; + } catch (ArgumentIncorrectException e) { + String msg = manager.getMessageHandler().getArgNotRecognized().replace("%arg%", e.getInput()); + manager.getPlatform().sendMessage(source, msg); + return true; + } + return true; + } + + /** + * Suggests command completions based on the provided source, base label, and arguments. + */ + public List suggest(S source, String base, String[] args) { + for (int i = args.length; i >= 0; i--) { + String label = buildLabel(base, args, i); + Map> map = manager.getCompleters().get(label); + if (map != null && map.containsKey(args.length)) { + return map.get(args.length) + .onCompletion(source, Collections.singletonList(buildArgsBefore(base, args))) + .stream() + .filter(opt -> allowedSuggestion(source, label, opt)) + .filter(opt -> matchesPrefix(opt, args[args.length - 1])) + .collect(Collectors.toList()); + } + } + return Collections.emptyList(); + } + + private String buildLabel(String base, String[] args, int count) { + StringBuilder sb = new StringBuilder(base.toLowerCase()); + for (int i = 0; i < count; i++) sb.append('.').append(args[i].toLowerCase()); + return sb.toString(); + } + + private String buildArgsBefore(String base, String[] args) { + if (args.length <= 1) return base; + return base + "." + String.join(".", Arrays.copyOf(args, args.length - 1)); + } + + private boolean matchesPrefix(String candidate, String current) { + String lower = current.toLowerCase(); + return candidate.equalsIgnoreCase(current) || candidate.toLowerCase().startsWith(lower); + } + + private boolean allowedSuggestion(S src, String label, String opt) { + String full = label + "." + opt.toLowerCase(); + Optional> copt = manager.getCommands().findNode(full.split("\\.")).flatMap(r -> r.node.getCommand()); + if (!copt.isPresent()) return true; + Command c = copt.get(); + return c.getRequirements().stream().allMatch(r -> r.check(src)) + && (c.getPermission().isEmpty() || manager.getPlatform().hasPermission(src, c.getPermission())); + } +} \ No newline at end of file diff --git a/core/src/main/java/fr/traqueur/commands/api/CommandPlatform.java b/core/src/main/java/fr/traqueur/commands/api/models/CommandPlatform.java similarity index 95% rename from core/src/main/java/fr/traqueur/commands/api/CommandPlatform.java rename to core/src/main/java/fr/traqueur/commands/api/models/CommandPlatform.java index 331c16a..2232542 100644 --- a/core/src/main/java/fr/traqueur/commands/api/CommandPlatform.java +++ b/core/src/main/java/fr/traqueur/commands/api/models/CommandPlatform.java @@ -1,4 +1,6 @@ -package fr.traqueur.commands.api; +package fr.traqueur.commands.api.models; + +import fr.traqueur.commands.api.CommandManager; import java.util.logging.Logger; diff --git a/core/src/main/java/fr/traqueur/commands/api/models/collections/CommandTree.java b/core/src/main/java/fr/traqueur/commands/api/models/collections/CommandTree.java new file mode 100644 index 0000000..6e934ce --- /dev/null +++ b/core/src/main/java/fr/traqueur/commands/api/models/collections/CommandTree.java @@ -0,0 +1,147 @@ +package fr.traqueur.commands.api.models.collections; + +import fr.traqueur.commands.api.models.Command; + +import java.util.*; + +/** + * A prefix-tree of commands, supporting nested labels and argument fallback. + */ +public class CommandTree { + /** + * Result of a lookup: the deepest matching node and leftover args. + */ + public static class MatchResult { + public final CommandNode node; + public final String[] args; + public MatchResult(CommandNode node, String[] args) { + this.node = node; + this.args = args; + } + } + + /** + * A node representing one segment in the command path. + */ + public static class CommandNode { + private final String label; + private final CommandNode parent; + private final Map> children = new HashMap<>(); + private Command command; + private boolean hadChildren = false; + + public CommandNode(String label, CommandNode parent) { + this.label = label; + this.parent = parent; + } + + /** segment without parent prefix */ + public String getLabel() { + return label; + } + + /** full path joined by dots */ + public String getFullLabel() { + if (parent == null || parent.label == null) return label; + return parent.getFullLabel() + "." + label; + } + + /** optional command at this node */ + public Optional> getCommand() { + return Optional.ofNullable(command); + } + + /** immutable view of children */ + public Map> getChildren() { + return Collections.unmodifiableMap(children); + } + } + + private final CommandNode root = new CommandNode<>(null, null); + + /** + * Add or replace a command at the given full label path (dot-separated). + * @param label full path like "hello.sub" + * @param command the command to attach at that path + */ + public void addCommand(String label, Command command) { + String[] parts = label.split("\\."); + CommandNode node = root; + for (String seg : parts) { + String key = seg.toLowerCase(); + node.hadChildren = true; + CommandNode finalNode = node; + node = node.children.computeIfAbsent(key, k -> new CommandNode<>(k, finalNode)); + } + node.command = command; + } + + /** + * Lookup a base label and raw arguments, returning matching node and leftover args. + */ + public Optional> findNode(String base, String[] rawArgs) { + if (base == null) return Optional.empty(); + CommandNode node = root.children.get(base.toLowerCase()); + if (node == null) return Optional.empty(); + + int i = 0; + while (i < rawArgs.length) { + String seg = rawArgs[i].toLowerCase(); + CommandNode child = node.children.get(seg); + if (child != null) { + node = child; + i++; + } else if (node.hadChildren) { + // expected a subcommand but not found + return Optional.empty(); + } else { + break; + } + } + String[] left = Arrays.copyOfRange(rawArgs, i, rawArgs.length); + return Optional.of(new MatchResult<>(node, left)); + } + + /** + * Lookup by full path segments, with no leftover args. + */ + public Optional> findNode(String[] segments) { + if (segments == null || segments.length == 0) return Optional.empty(); + CommandNode node = root; + for (String seg : segments) { + node = node.children.get(seg.toLowerCase()); + if (node == null) return Optional.empty(); + } + return Optional.of(new MatchResult<>(node, new String[]{})); + } + + /** + * Remove a command node by its full label. + * @param label full path like "root.sub" + * @param prune if true, remove entire subtree; otherwise just clear the command at that node + */ + public void removeCommand(String label, boolean prune) { + String[] parts = label.split("\\."); + CommandNode node = root; + for (String seg : parts) { + node = node.children.get(seg.toLowerCase()); + if (node == null) return; + } + CommandNode parent = node.parent; + if (parent == null) return; // cannot remove root + + boolean hasChildren = !node.children.isEmpty(); + if (prune || !hasChildren) { + // remove this node and entire subtree + parent.children.remove(node.label); + } else { + // clear only the command, keep subtree intact + node.command = null; + } + } + + /** Access to the virtual root. */ + public CommandNode getRoot() { + return root; + } +} diff --git a/core/src/test/java/fr/traqueur/commands/api/CommandManagerTest.java b/core/src/test/java/fr/traqueur/commands/api/CommandManagerTest.java index f71e3f1..67a6714 100644 --- a/core/src/test/java/fr/traqueur/commands/api/CommandManagerTest.java +++ b/core/src/test/java/fr/traqueur/commands/api/CommandManagerTest.java @@ -1,7 +1,11 @@ package fr.traqueur.commands.api; +import fr.traqueur.commands.api.arguments.Arguments; import fr.traqueur.commands.api.arguments.TabCompleter; import fr.traqueur.commands.api.exceptions.ArgumentIncorrectException; +import fr.traqueur.commands.api.models.Command; +import fr.traqueur.commands.api.models.CommandPlatform; +import fr.traqueur.commands.api.models.collections.CommandTree; import fr.traqueur.commands.impl.logging.InternalLogger; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -82,7 +86,6 @@ void testInfiniteArgsStopsFurtherParsing() throws Exception { @Test void testNoExtraAfterInfinite() throws Exception { - // Vérifier que l'ajout d'un argument après un infini lève une exception gérée Command cmd = new DummyCommand(); cmd.setManager(manager); cmd.addArgs("x:infinite"); @@ -138,28 +141,26 @@ void testArgumentIncorrectException_onBadType() { } @Test - void testCommandRegistration_entriesInManager() { + void testCommandRegistration_entriesInTree() { Command cmd = new DummyCommand("main"); cmd.addAlias("m"); cmd.addSubCommand(new DummyCommand("sub")); manager.registerCommand(cmd); - Map> map = manager.getCommands(); - assertTrue(map.containsKey("main")); - assertTrue(map.containsKey("m")); - assertTrue(map.containsKey("main.sub")); + CommandTree tree = manager.getCommands(); + assertTrue(tree.getRoot().getChildren().containsKey("main")); + assertTrue(tree.getRoot().getChildren().containsKey("m")); + assertTrue(tree.findNode("main", new String[]{"sub"}).isPresent()); } @Test void registerCommand_shouldAddMainAndAliasAndSubcommands() { - // Create command with alias and subcommand DummyCommand main = new DummyCommand(); main.addAlias("a1", "a2"); DummyCommand sub = new DummyCommand(); main.addSubCommand(sub); manager.registerCommand(main); - // Check platform.addCommand called for all labels List added = platform.added; assertTrue(added.contains("dummy")); assertTrue(added.contains("a1")); @@ -169,7 +170,6 @@ void registerCommand_shouldAddMainAndAliasAndSubcommands() { @Test void addCommand_shouldRegisterCompletersForArgs() { - // Create a command requiring two args with converters Command cmd = new DummyCommand(); cmd.addArgs("intArg", Integer.class); cmd.addOptionalArgs("optArg", Double.class); diff --git a/core/src/test/java/fr/traqueur/commands/api/ArgumentsTest.java b/core/src/test/java/fr/traqueur/commands/api/arguments/ArgumentsTest.java similarity index 98% rename from core/src/test/java/fr/traqueur/commands/api/ArgumentsTest.java rename to core/src/test/java/fr/traqueur/commands/api/arguments/ArgumentsTest.java index 3798593..681cdca 100644 --- a/core/src/test/java/fr/traqueur/commands/api/ArgumentsTest.java +++ b/core/src/test/java/fr/traqueur/commands/api/arguments/ArgumentsTest.java @@ -1,4 +1,4 @@ -package fr.traqueur.commands.api; +package fr.traqueur.commands.api.arguments; import fr.traqueur.commands.impl.logging.InternalLogger; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; diff --git a/core/src/test/java/fr/traqueur/commands/api/CommandInvokerTest.java b/core/src/test/java/fr/traqueur/commands/api/models/CommandInvokerTest.java similarity index 84% rename from core/src/test/java/fr/traqueur/commands/api/CommandInvokerTest.java rename to core/src/test/java/fr/traqueur/commands/api/models/CommandInvokerTest.java index e732ae8..ca43250 100644 --- a/core/src/test/java/fr/traqueur/commands/api/CommandInvokerTest.java +++ b/core/src/test/java/fr/traqueur/commands/api/models/CommandInvokerTest.java @@ -1,23 +1,24 @@ -package fr.traqueur.commands.api; +package fr.traqueur.commands.api.models; +import fr.traqueur.commands.api.CommandManager; +import fr.traqueur.commands.api.arguments.Arguments; import fr.traqueur.commands.api.exceptions.ArgumentIncorrectException; import fr.traqueur.commands.api.logging.MessageHandler; +import fr.traqueur.commands.api.models.collections.CommandTree; import fr.traqueur.commands.api.requirements.Requirement; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import java.util.Collections; -import java.util.HashMap; -import java.util.Map; import java.util.concurrent.atomic.AtomicBoolean; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; -@SuppressWarnings(value = "unchecked") +@SuppressWarnings("unchecked") class CommandInvokerTest { private CommandManager manager; + private CommandTree tree; private CommandPlatform platform; private MessageHandler messageHandler; private CommandInvoker invoker; @@ -25,21 +26,19 @@ class CommandInvokerTest { @BeforeEach void setup() { - // Mock platform and manager platform = mock(CommandPlatform.class); messageHandler = mock(MessageHandler.class); manager = mock(CommandManager.class); when(manager.getPlatform()).thenReturn(platform); when(manager.getMessageHandler()).thenReturn(messageHandler); - // Default platform behaviors when(platform.isPlayer(anyString())).thenReturn(true); when(platform.hasPermission(anyString(), anyString())).thenReturn(true); - // Register single command under key "base" + cmd = new DummyCommand(); - Map> map = new HashMap<>(); - map.put("base", cmd); - when(manager.getCommands()).thenReturn(map); - // Create invoker + tree = new CommandTree<>(); + tree.addCommand("base",cmd); + when(manager.getCommands()).thenReturn(tree); + invoker = new CommandInvoker<>(manager); } @@ -57,7 +56,6 @@ void invoke_inGameOnly_nonPlayer_sendsOnlyInGame() { when(messageHandler.getOnlyInGameMessage()).thenReturn("ONLY_IN_GAME"); invoker.invoke("user", "base", new String[]{}); - verify(platform).sendMessage("user", "ONLY_IN_GAME"); } @@ -68,7 +66,6 @@ void invoke_noPermission_sendsNoPermission() { when(messageHandler.getNoPermissionMessage()).thenReturn("NO_PERMISSION"); invoker.invoke("user", "base", new String[]{}); - verify(platform).sendMessage("user", "NO_PERMISSION"); } @@ -80,7 +77,6 @@ void invoke_requirementFails_sendsRequirementError() { cmd.addRequirements(req); invoker.invoke("user", "base", new String[]{}); - verify(platform).sendMessage("user", "REQ_ERR"); } @@ -90,18 +86,17 @@ void invoke_wrongArgCount_sendsUsage() { cmd.setUsage("/base "); invoker.invoke("user", "base", new String[]{}); - verify(platform).sendMessage("user", "/base "); } @Test void invoke_parseThrowsArgumentIncorrect_sendsArgNotRecognized() throws Exception { cmd.addArgs("a", String.class); - when(manager.parse(eq(cmd), any(String[].class))).thenThrow(new ArgumentIncorrectException("bad")); + when(manager.parse(eq(cmd), any(String[].class))) + .thenThrow(new ArgumentIncorrectException("bad")); when(messageHandler.getArgNotRecognized()).thenReturn("ARG_ERR %arg%"); invoker.invoke("user", "base", new String[]{"bad"}); - verify(platform).sendMessage("user", "ARG_ERR bad"); } @@ -115,10 +110,13 @@ public void execute(String sender, Arguments arguments) { } }; custom.addArgs("x", String.class); - when(manager.getCommands()).thenReturn(Collections.singletonMap("base", custom)); - boolean result = invoker.invoke("user", "base", new String[]{"hello"}); + tree = new CommandTree<>(); + tree.addCommand("base",custom); + when(manager.getCommands()).thenReturn(tree); + invoker = new CommandInvoker<>(manager); + boolean result = invoker.invoke("user", "base", new String[]{"hello"}); assertTrue(result); assertTrue(executed.get()); } diff --git a/core/src/test/java/fr/traqueur/commands/api/CommandTest.java b/core/src/test/java/fr/traqueur/commands/api/models/CommandTest.java similarity index 96% rename from core/src/test/java/fr/traqueur/commands/api/CommandTest.java rename to core/src/test/java/fr/traqueur/commands/api/models/CommandTest.java index 459edaf..25853a1 100644 --- a/core/src/test/java/fr/traqueur/commands/api/CommandTest.java +++ b/core/src/test/java/fr/traqueur/commands/api/models/CommandTest.java @@ -1,7 +1,9 @@ // Placez ce fichier sous core/src/test/java/fr/traqueur/commands/api/ -package fr.traqueur.commands.api; +package fr.traqueur.commands.api.models; +import fr.traqueur.commands.api.CommandManager; +import fr.traqueur.commands.api.arguments.Arguments; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -48,10 +50,10 @@ void setUp() { @Test void testAliasesAndName() { assertEquals("dummy", cmd.getName()); - assertTrue(cmd.getAliases().isEmpty()); + assertEquals(1, cmd.getAliases().size()); cmd.addAlias("d1", "d2"); List aliases = cmd.getAliases(); - assertEquals(2, aliases.size()); + assertEquals(3, cmd.getAliases().size()); assertTrue(aliases.contains("d1")); assertTrue(aliases.contains("d2")); } diff --git a/core/src/test/java/fr/traqueur/commands/api/models/collections/CommandTreeTest.java b/core/src/test/java/fr/traqueur/commands/api/models/collections/CommandTreeTest.java new file mode 100644 index 0000000..cb7f0c8 --- /dev/null +++ b/core/src/test/java/fr/traqueur/commands/api/models/collections/CommandTreeTest.java @@ -0,0 +1,127 @@ +package fr.traqueur.commands.api.models.collections; + +import fr.traqueur.commands.api.models.Command; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; + +class CommandTreeTest { + private CommandTree tree; + private StubCommand rootCmd; + private StubCommand subCmd; + private StubCommand subSubCmd; + + @BeforeEach + void setup() { + tree = new CommandTree<>(); + rootCmd = new StubCommand("root"); + subCmd = new StubCommand("sub"); + subSubCmd = new StubCommand("subsub"); + } + + @Test + void testAddAndFindRoot() { + tree.addCommand("root",rootCmd); + // find base with no args + Optional> match = tree.findNode("root", new String[]{}); + assertTrue(match.isPresent()); + assertEquals(rootCmd, match.get().node.getCommand().orElse(null)); + // full label + assertEquals("root", match.get().node.getFullLabel()); + } + + @Test + void testAddNestedAndFind() { + // create root.sub command hierarchy + rootCmd.addSubCommand(subCmd); + subCmd.addSubCommand(subSubCmd); + tree.addCommand("root",rootCmd); + tree.addCommand("root.sub",subCmd); + tree.addCommand("root.sub.subsub",subSubCmd); + + // find sub + Optional> m1 = tree.findNode("root", new String[]{"sub"}); + assertTrue(m1.isPresent()); + assertEquals(subCmd, m1.get().node.getCommand().orElse(null)); + assertArrayEquals(new String[]{}, m1.get().args); + + // find sub.subsub + Optional> m2 = tree.findNode("root", new String[]{"sub", "subsub"}); + assertTrue(m2.isPresent()); + assertEquals(subSubCmd, m2.get().node.getCommand().orElse(null)); + assertArrayEquals(new String[]{}, m2.get().args); + } + + @Test + void testFindNodeWithExtraArgs() { + tree.addCommand("root",rootCmd); + // root takes no args, so extra args are leftover + Optional> m = tree.findNode("root", new String[]{"a","b","c"}); + assertTrue(m.isPresent()); + assertEquals(rootCmd, m.get().node.getCommand().orElse(null)); + assertArrayEquals(new String[]{"a","b","c"}, m.get().args); + } + + @Test + void testFindNonexistent() { + tree.addCommand("root",rootCmd); + Optional> m = tree.findNode("unknown", new String[]{}); + assertFalse(m.isPresent()); + } + + @Test + void testRemoveCommandClearOnly() { + tree.addCommand("root",rootCmd); + // remove without subcommands flag false, but no children => pruned + tree.removeCommand("root", false); + Optional> m = tree.findNode("root", new String[]{}); + assertFalse(m.isPresent()); + } + + @Test + void testRemoveCommandKeepChildren() { + // add root and sub + rootCmd.addSubCommand(subCmd); + tree.addCommand("root", rootCmd); + tree.addCommand("root.sub",subCmd); + + // remove root only, keep children + tree.removeCommand("root", false); + // root command cleared but sub-tree remains + Optional> mSub = tree.findNode("root", new String[]{"sub"}); + assertTrue(mSub.isPresent()); + assertEquals(subCmd, mSub.get().node.getCommand().orElse(null)); + + // find root itself => cleared, so no command at root + Optional> mRoot = tree.findNode("root", new String[]{}); + assertFalse(mRoot.get().node.getCommand().isPresent()); + } + + @Test + void testRemoveCommandPruneBranch() { + // add nested commands + rootCmd.addSubCommand(subCmd); + subCmd.addSubCommand(subSubCmd); + tree.addCommand("root",rootCmd); + tree.addCommand("root.sub",subCmd); + tree.addCommand("root.sub.subsub",subSubCmd); + + // remove entire branch + tree.removeCommand("root.sub", true); + // sub and subsub removed + assertFalse(tree.findNode("root", new String[]{"sub"}).isPresent()); + assertFalse(tree.findNode("root", new String[]{"sub","subsub"}).isPresent()); + // root remains + assertTrue(tree.findNode("root", new String[]{}).isPresent()); + } + + // stub Command to use in tests + static class StubCommand extends Command { + public StubCommand(String name) { super(null, name); } + @Override public void execute(String sender, fr.traqueur.commands.api.arguments.Arguments args) {} + } +} diff --git a/spigot-test-plugin/src/main/java/fr/traqueur/testplugin/Sub2TestCommand.java b/spigot-test-plugin/src/main/java/fr/traqueur/testplugin/Sub2TestCommand.java index 42b51b6..5f42c9d 100644 --- a/spigot-test-plugin/src/main/java/fr/traqueur/testplugin/Sub2TestCommand.java +++ b/spigot-test-plugin/src/main/java/fr/traqueur/testplugin/Sub2TestCommand.java @@ -1,6 +1,6 @@ package fr.traqueur.testplugin; -import fr.traqueur.commands.api.Arguments; +import fr.traqueur.commands.api.arguments.Arguments; import fr.traqueur.commands.spigot.Command; import org.bukkit.command.CommandSender; diff --git a/spigot-test-plugin/src/main/java/fr/traqueur/testplugin/SubTestCommand.java b/spigot-test-plugin/src/main/java/fr/traqueur/testplugin/SubTestCommand.java index 4c9cd63..d1ade95 100644 --- a/spigot-test-plugin/src/main/java/fr/traqueur/testplugin/SubTestCommand.java +++ b/spigot-test-plugin/src/main/java/fr/traqueur/testplugin/SubTestCommand.java @@ -1,6 +1,6 @@ package fr.traqueur.testplugin; -import fr.traqueur.commands.api.Arguments; +import fr.traqueur.commands.api.arguments.Arguments; import fr.traqueur.commands.spigot.Command; import org.bukkit.command.CommandSender; diff --git a/spigot-test-plugin/src/main/java/fr/traqueur/testplugin/TestCommand.java b/spigot-test-plugin/src/main/java/fr/traqueur/testplugin/TestCommand.java index 8747c38..6cd38da 100644 --- a/spigot-test-plugin/src/main/java/fr/traqueur/testplugin/TestCommand.java +++ b/spigot-test-plugin/src/main/java/fr/traqueur/testplugin/TestCommand.java @@ -1,6 +1,6 @@ package fr.traqueur.testplugin; -import fr.traqueur.commands.api.Arguments; +import fr.traqueur.commands.api.arguments.Arguments; import fr.traqueur.commands.spigot.Command; import org.bukkit.command.CommandSender; diff --git a/spigot/src/main/java/fr/traqueur/commands/spigot/Command.java b/spigot/src/main/java/fr/traqueur/commands/spigot/Command.java index c4ffd82..6349678 100644 --- a/spigot/src/main/java/fr/traqueur/commands/spigot/Command.java +++ b/spigot/src/main/java/fr/traqueur/commands/spigot/Command.java @@ -4,10 +4,10 @@ import org.bukkit.plugin.java.JavaPlugin; /** - * This implementation of {@link fr.traqueur.commands.api.Command} is used to provide a command in Spigot. + * This implementation of {@link fr.traqueur.commands.api.models.Command} is used to provide a command in Spigot. * @param is the type of the plugin, which must extend the main plugin class. */ -public abstract class Command extends fr.traqueur.commands.api.Command { +public abstract class Command extends fr.traqueur.commands.api.models.Command { /** * The constructor of the command. diff --git a/spigot/src/main/java/fr/traqueur/commands/spigot/SpigotPlatform.java b/spigot/src/main/java/fr/traqueur/commands/spigot/SpigotPlatform.java index 9940ea2..7aad6e0 100644 --- a/spigot/src/main/java/fr/traqueur/commands/spigot/SpigotPlatform.java +++ b/spigot/src/main/java/fr/traqueur/commands/spigot/SpigotPlatform.java @@ -1,8 +1,8 @@ package fr.traqueur.commands.spigot; -import fr.traqueur.commands.api.Command; +import fr.traqueur.commands.api.models.Command; import fr.traqueur.commands.api.CommandManager; -import fr.traqueur.commands.api.CommandPlatform; +import fr.traqueur.commands.api.models.CommandPlatform; import org.bukkit.Bukkit; import org.bukkit.ChatColor; import org.bukkit.command.CommandMap; @@ -129,44 +129,41 @@ public void sendMessage(CommandSender sender, String message) { public void addCommand(Command command, String label) { String[] labelParts = label.split("\\."); String cmdLabel = labelParts[0].toLowerCase(); - AtomicReference originCmdLabelRef = new AtomicReference<>(cmdLabel); - int labelSize = labelParts.length; - - if(labelSize > 1) { - this.commandManager.getCommands().values().stream() - .filter(commandInner -> !commandInner.isSubCommand()) - .filter(commandInner -> commandInner.getAliases().contains(cmdLabel)) - .findAny() - .ifPresent(commandInner -> originCmdLabelRef.set(commandInner.getName())); - } else { - originCmdLabelRef.set(label); - } - String originCmdLabel = originCmdLabelRef.get(); - if (commandMap.getCommand(originCmdLabel) == null) { - PluginCommand cmd; + boolean alreadyInTree = commandManager.getCommands() + .getRoot() + .getChildren() + .containsKey(cmdLabel); + boolean alreadyInMap = commandMap.getCommand(cmdLabel) != null; + + if (!alreadyInTree && !alreadyInMap) { try { - cmd = pluginConstructor.newInstance(originCmdLabel, this.plugin); - } catch (InstantiationException | IllegalAccessException | InvocationTargetException e) { + PluginCommand cmd = pluginConstructor.newInstance(cmdLabel, plugin); + cmd.setExecutor(spigotExecutor); + cmd.setTabCompleter(spigotExecutor); + cmd.setAliases( + command.getAliases().stream() + .map(a -> a.split("\\.")[0]) + .filter(a -> !a.equalsIgnoreCase(cmdLabel)) + .distinct() + .collect(Collectors.toList()) + ); + + if (!commandMap.register(cmdLabel, plugin.getName(), cmd)) { + getLogger().severe("Unable to add command " + cmdLabel); + return; + } + } catch (Exception e) { throw new RuntimeException(e); } - - cmd.setExecutor(this.spigotExecutor); - cmd.setTabCompleter(this.spigotExecutor); - cmd.setAliases(command.getAliases().stream().map(s -> s.split("\\.")[0]).collect(Collectors.toList())); - - if(!commandMap.register(originCmdLabel, this.plugin.getName(), cmd)) { - this.getLogger().severe("Unable to add the command " + originCmdLabel); - return; - } } if (!command.getDescription().equalsIgnoreCase("") && labelParts.length == 1) { - Objects.requireNonNull(commandMap.getCommand(originCmdLabel)).setDescription(command.getDescription()); + Objects.requireNonNull(commandMap.getCommand(cmdLabel)).setDescription(command.getDescription()); } if (!command.getUsage().equalsIgnoreCase("") && labelParts.length == 1) { - Objects.requireNonNull(commandMap.getCommand(originCmdLabel)).setUsage(command.getUsage()); + Objects.requireNonNull(commandMap.getCommand(cmdLabel)).setUsage(command.getUsage()); } } diff --git a/spigot/src/test/java/fr/traqueur/commands/spigot/SpigotIntegrationTest.java b/spigot/src/test/java/fr/traqueur/commands/spigot/SpigotIntegrationTest.java index 01d206d..e8b62da 100644 --- a/spigot/src/test/java/fr/traqueur/commands/spigot/SpigotIntegrationTest.java +++ b/spigot/src/test/java/fr/traqueur/commands/spigot/SpigotIntegrationTest.java @@ -1,8 +1,8 @@ package fr.traqueur.commands.spigot; -import fr.traqueur.commands.api.Arguments; -import fr.traqueur.commands.api.Command; -import fr.traqueur.commands.api.CommandPlatform; +import fr.traqueur.commands.api.arguments.Arguments; +import fr.traqueur.commands.api.models.Command; +import fr.traqueur.commands.api.models.CommandPlatform; import fr.traqueur.commands.spigot.arguments.PlayerArgument; import org.bukkit.Bukkit; import org.bukkit.command.CommandSender; @@ -29,7 +29,7 @@ void setUp() { @Override public boolean hasPermission(CommandSender sender, String permission) { return true; } @Override public boolean isPlayer(CommandSender sender) {return sender instanceof Player;} @Override public void sendMessage(CommandSender sender, String message) {} - @Override public void addCommand(fr.traqueur.commands.api.Command command, String label) {} + @Override public void addCommand(Command command, String label) {} @Override public void removeCommand(String label, boolean subcommand) {} }) {}; manager.registerConverter(Player.class, new PlayerArgument()); diff --git a/velocity-test-plugin/src/main/java/fr/traqueur/velocityTestPlugin/Sub2TestCommand.java b/velocity-test-plugin/src/main/java/fr/traqueur/velocityTestPlugin/Sub2TestCommand.java index 4ca316a..72a3b00 100644 --- a/velocity-test-plugin/src/main/java/fr/traqueur/velocityTestPlugin/Sub2TestCommand.java +++ b/velocity-test-plugin/src/main/java/fr/traqueur/velocityTestPlugin/Sub2TestCommand.java @@ -1,7 +1,7 @@ package fr.traqueur.velocityTestPlugin; import com.velocitypowered.api.command.CommandSource; -import fr.traqueur.commands.api.Arguments; +import fr.traqueur.commands.api.arguments.Arguments; import fr.traqueur.commands.velocity.Command; import net.kyori.adventure.text.Component; diff --git a/velocity-test-plugin/src/main/java/fr/traqueur/velocityTestPlugin/SubTestCommand.java b/velocity-test-plugin/src/main/java/fr/traqueur/velocityTestPlugin/SubTestCommand.java index 384181b..445272d 100644 --- a/velocity-test-plugin/src/main/java/fr/traqueur/velocityTestPlugin/SubTestCommand.java +++ b/velocity-test-plugin/src/main/java/fr/traqueur/velocityTestPlugin/SubTestCommand.java @@ -1,7 +1,7 @@ package fr.traqueur.velocityTestPlugin; import com.velocitypowered.api.command.CommandSource; -import fr.traqueur.commands.api.Arguments; +import fr.traqueur.commands.api.arguments.Arguments; import fr.traqueur.commands.velocity.Command; import net.kyori.adventure.text.Component; diff --git a/velocity-test-plugin/src/main/java/fr/traqueur/velocityTestPlugin/TestCommand.java b/velocity-test-plugin/src/main/java/fr/traqueur/velocityTestPlugin/TestCommand.java index 5b4bace..74f0603 100644 --- a/velocity-test-plugin/src/main/java/fr/traqueur/velocityTestPlugin/TestCommand.java +++ b/velocity-test-plugin/src/main/java/fr/traqueur/velocityTestPlugin/TestCommand.java @@ -1,7 +1,7 @@ package fr.traqueur.velocityTestPlugin; import com.velocitypowered.api.command.CommandSource; -import fr.traqueur.commands.api.Arguments; +import fr.traqueur.commands.api.arguments.Arguments; import fr.traqueur.commands.velocity.Command; import net.kyori.adventure.text.Component; diff --git a/velocity/src/main/java/fr/traqueur/commands/velocity/Command.java b/velocity/src/main/java/fr/traqueur/commands/velocity/Command.java index e2425ee..f65e016 100644 --- a/velocity/src/main/java/fr/traqueur/commands/velocity/Command.java +++ b/velocity/src/main/java/fr/traqueur/commands/velocity/Command.java @@ -3,10 +3,10 @@ import com.velocitypowered.api.command.CommandSource; /** - * This implementation of {@link fr.traqueur.commands.api.Command} is used to provide a command in Spigot. + * This implementation of {@link fr.traqueur.commands.api.models.Command} is used to provide a command in Spigot. * @param is the type of the plugin, which must extend the main plugin class. */ -public abstract class Command extends fr.traqueur.commands.api.Command { +public abstract class Command extends fr.traqueur.commands.api.models.Command { /** * The constructor of the command. * diff --git a/velocity/src/main/java/fr/traqueur/commands/velocity/VelocityPlatform.java b/velocity/src/main/java/fr/traqueur/commands/velocity/VelocityPlatform.java index fd07b4d..90f83f7 100644 --- a/velocity/src/main/java/fr/traqueur/commands/velocity/VelocityPlatform.java +++ b/velocity/src/main/java/fr/traqueur/commands/velocity/VelocityPlatform.java @@ -2,9 +2,9 @@ import com.velocitypowered.api.command.CommandSource; import com.velocitypowered.api.proxy.ProxyServer; -import fr.traqueur.commands.api.Command; +import fr.traqueur.commands.api.models.Command; import fr.traqueur.commands.api.CommandManager; -import fr.traqueur.commands.api.CommandPlatform; +import fr.traqueur.commands.api.models.CommandPlatform; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.minimessage.MiniMessage; import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer; @@ -117,30 +117,24 @@ public void sendMessage(CommandSource sender, String message) { public void addCommand(Command command, String label) { String[] labelParts = label.split("\\."); String cmdLabel = labelParts[0].toLowerCase(); - AtomicReference originCmdLabelRef = new AtomicReference<>(cmdLabel); - - if (labelParts.length > 1) { - this.commandManager.getCommands().values().stream() - .filter(commandInner -> !commandInner.isSubCommand()) - .filter(commandInner -> commandInner.getAliases().contains(cmdLabel)) - .findAny() - .ifPresent(commandInner -> originCmdLabelRef.set(commandInner.getName())); - } else { - originCmdLabelRef.set(label); - } - String originCmdLabel = originCmdLabelRef.get().toLowerCase(); com.velocitypowered.api.command.CommandManager velocityCmdManager = server.getCommandManager(); - if (velocityCmdManager.getCommandMeta(originCmdLabel) == null) { + boolean alreadyInTree = commandManager.getCommands() + .getRoot() + .getChildren() + .containsKey(cmdLabel); + boolean alreadyInMap = velocityCmdManager.getCommandMeta(cmdLabel) != null; + + if (!alreadyInTree && !alreadyInMap) { String[] aliases = command.getAliases().stream() .map(a -> a.split("\\.")[0].toLowerCase()) + .filter(a -> !a.equalsIgnoreCase(cmdLabel)) .distinct() - .filter(a -> !a.equalsIgnoreCase(originCmdLabel)) .toArray(String[]::new); velocityCmdManager.register( - velocityCmdManager.metaBuilder(originCmdLabel) + velocityCmdManager.metaBuilder(cmdLabel) .aliases(aliases) .plugin(plugin) .build(), From 1e66096e3a9cbb95a8d068953956c53950fa6974 Mon Sep 17 00:00:00 2001 From: Traqueur_ Date: Mon, 7 Jul 2025 21:07:41 +0200 Subject: [PATCH 2/5] feat(javadoc); add javadoc for the new element and add test for the new system --- build.gradle | 2 +- core/build.gradle | 6 ++ .../commands/CommandLookupBenchmark.java | 80 +++++++++++++++++++ .../commands/api/models/CommandInvoker.java | 12 +++ .../api/models/collections/CommandTree.java | 66 +++++++++++++-- 5 files changed, 159 insertions(+), 7 deletions(-) create mode 100644 core/src/jmh/java/fr/traqueur/commands/CommandLookupBenchmark.java diff --git a/build.gradle b/build.gradle index ed4bb89..e5ce898 100644 --- a/build.gradle +++ b/build.gradle @@ -1,5 +1,5 @@ plugins { - id 'com.adarshr.test-logger' version '4.0.0' + id("com.adarshr.test-logger") version "4.0.0" } allprojects { diff --git a/core/build.gradle b/core/build.gradle index 0b8a4fb..9f48b74 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -1,5 +1,6 @@ plugins { id 'maven-publish' + id("me.champeau.jmh") version "0.7.3" } java { @@ -9,6 +10,11 @@ java { withJavadocJar() } +dependencies { + jmh 'org.openjdk.jmh:jmh-core:1.37' + jmh 'org.openjdk.jmh:jmh-generator-annprocess:1.37' +} + def generatedResourcesDir = "$buildDir/generated-resources" tasks.register('generateCommandsProperties') { diff --git a/core/src/jmh/java/fr/traqueur/commands/CommandLookupBenchmark.java b/core/src/jmh/java/fr/traqueur/commands/CommandLookupBenchmark.java new file mode 100644 index 0000000..eaed6a6 --- /dev/null +++ b/core/src/jmh/java/fr/traqueur/commands/CommandLookupBenchmark.java @@ -0,0 +1,80 @@ +package fr.traqueur.commands; + +import fr.traqueur.commands.api.models.Command; +import fr.traqueur.commands.api.models.collections.CommandTree; +import org.openjdk.jmh.annotations.*; + +import java.util.*; +import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.TimeUnit; + +@State(Scope.Benchmark) +@Measurement(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS) +@Warmup(iterations = 3, time = 1, timeUnit = TimeUnit.SECONDS) +@Fork(2) +@BenchmarkMode(Mode.Throughput) +@OutputTimeUnit(TimeUnit.MILLISECONDS) +public class CommandLookupBenchmark { + + @Param({ "1000", "10000", "50000" }) + public int N; + + @Param({ "1", "2", "3" }) + public int maxDepth; + + private Map flatMap; + private CommandTree tree; + private String[] rawLabels; + + @Setup(Level.Trial) + public void setup() { + flatMap = new HashMap<>(N); + tree = new CommandTree<>(); + + rawLabels = new String[N]; + ThreadLocalRandom rnd = ThreadLocalRandom.current(); + + for (int i = 0; i < N; i++) { + int depth = 1 + rnd.nextInt(maxDepth); + StringBuilder sb = new StringBuilder(); + for (int d = 0; d < depth; d++) { + if (d > 0) sb.append('.'); + sb.append("cmd").append(rnd.nextInt(N * 10)); + } + String label = sb.toString(); + rawLabels[i] = label; + + DummyCommand cmd = new DummyCommand(label); + flatMap.put(label, cmd); + tree.addCommand(label, cmd); + } + } + + @Benchmark + public DummyCommand mapLookup() { + String raw = rawLabels[ThreadLocalRandom.current().nextInt(N)]; + String[] segments = raw.split("\\."); + for (int len = segments.length; len > 0; len--) { + String key = String.join(".", Arrays.copyOf(segments, len)); + DummyCommand c = flatMap.get(key); + if (c != null) { + return c; + } + } + return null; + } + + @Benchmark + public CommandTree.MatchResult treeLookup() { + String raw = rawLabels[ThreadLocalRandom.current().nextInt(N)]; + String[] parts = raw.split("\\."); + String base = parts[0]; + String[] sub = Arrays.copyOfRange(parts, 1, parts.length); + return tree.findNode(base, sub).orElse(null); + } + + public static class DummyCommand extends Command { + public DummyCommand(String name) { super(null, name); } + @Override public void execute(Object s, fr.traqueur.commands.api.arguments.Arguments a) {} + } +} diff --git a/core/src/main/java/fr/traqueur/commands/api/models/CommandInvoker.java b/core/src/main/java/fr/traqueur/commands/api/models/CommandInvoker.java index 9d2577d..546462d 100644 --- a/core/src/main/java/fr/traqueur/commands/api/models/CommandInvoker.java +++ b/core/src/main/java/fr/traqueur/commands/api/models/CommandInvoker.java @@ -24,12 +24,19 @@ public class CommandInvoker { private final CommandManager manager; + /** + * Constructs a CommandInvoker with the given command manager. + * @param manager the command manager to use for command handling + */ public CommandInvoker(CommandManager manager) { this.manager = manager; } /** * Invokes a command based on the provided source, base label, and raw arguments. + * @param base the base command label (e.g. "hello") + * @param rawArgs the arguments of the command + * @param source the command sender (e.g. a player or console) * @return true if a command handler was executed or a message sent; false if command not found */ public boolean invoke(S source, String base, String[] rawArgs) { @@ -92,6 +99,11 @@ public boolean invoke(S source, String base, String[] rawArgs) { /** * Suggests command completions based on the provided source, base label, and arguments. + * This method checks for available tab completers and filters suggestions based on the current input. + * @param source the command sender (e.g. a player or console) + * @param base the command label (e.g. "hello") + * @param args the arguments provided to the command + * @return the list of suggestion */ public List suggest(S source, String base, String[] args) { for (int i = args.length; i >= 0; i--) { diff --git a/core/src/main/java/fr/traqueur/commands/api/models/collections/CommandTree.java b/core/src/main/java/fr/traqueur/commands/api/models/collections/CommandTree.java index 6e934ce..ed3bbb7 100644 --- a/core/src/main/java/fr/traqueur/commands/api/models/collections/CommandTree.java +++ b/core/src/main/java/fr/traqueur/commands/api/models/collections/CommandTree.java @@ -6,14 +6,32 @@ /** * A prefix-tree of commands, supporting nested labels and argument fallback. + * @param type of the command context + * @param type of the command sender */ public class CommandTree { /** * Result of a lookup: the deepest matching node and leftover args. + * This is used to find commands based on a base label and raw arguments. + * @param type of the command context + * @param type of the command sender */ public static class MatchResult { + + /** The node that matched the base label and any subcommands. + * The args are the remaining segments after the match. + */ public final CommandNode node; + + /** Remaining arguments after the matched node. + * This can be empty if the match was exact. + */ public final String[] args; + + /** Create a match result with the node and leftover args. + * @param node the matched command node + * @param args remaining arguments after the match + */ public MatchResult(CommandNode node, String[] args) { this.node = node; this.args = args; @@ -22,42 +40,68 @@ public MatchResult(CommandNode node, String[] args) { /** * A node representing one segment in the command path. + * Each node can have a command associated with it, + * @param type of the command context + * @param type of the command sender */ public static class CommandNode { + private final String label; private final CommandNode parent; private final Map> children = new HashMap<>(); private Command command; private boolean hadChildren = false; + /** Create a new command node with the given label and optional parent. + * @param label the segment label, e.g. "hello" + * @param parent the parent node, or null for root + */ public CommandNode(String label, CommandNode parent) { this.label = label; this.parent = parent; } - /** segment without parent prefix */ + /** Get the label of this node segment. + * @return the label like "hello" + */ public String getLabel() { return label; } - /** full path joined by dots */ + /** + * Get the full label path including parent segments. + * @return the full label like "parent.child" + */ public String getFullLabel() { if (parent == null || parent.label == null) return label; return parent.getFullLabel() + "." + label; } - /** optional command at this node */ + /** Get the command associated with this node, if any. + * @return the command, or empty if not set + */ public Optional> getCommand() { return Optional.ofNullable(command); } - /** immutable view of children */ + /** Get the parent node, or null if this is the root. + * @return the parent node, or null for root + */ public Map> getChildren() { return Collections.unmodifiableMap(children); } } - private final CommandNode root = new CommandNode<>(null, null); + private final CommandNode root; + + + /** + * Create an empty command tree with a root node. + * The root node has no label and serves as the starting point for all commands. + */ + public CommandTree() { + this.root = new CommandNode<>(null, null); + } /** * Add or replace a command at the given full label path (dot-separated). @@ -78,6 +122,10 @@ public void addCommand(String label, Command command) { /** * Lookup a base label and raw arguments, returning matching node and leftover args. + * This allows for partial matches where the command may have subcommands. + * @param base the base command label, e.g. "hello" + * @param rawArgs the raw arguments to match against subcommands + * @return an Optional containing the match result, or empty if not found */ public Optional> findNode(String base, String[] rawArgs) { if (base == null) return Optional.empty(); @@ -104,6 +152,9 @@ public Optional> findNode(String base, String[] rawArgs) { /** * Lookup by full path segments, with no leftover args. + * This finds the exact node matching all segments. + * @param segments the path segments like ["root", "sub"] + * @return an Optional containing the match result, or empty if not found */ public Optional> findNode(String[] segments) { if (segments == null || segments.length == 0) return Optional.empty(); @@ -140,7 +191,10 @@ public void removeCommand(String label, boolean prune) { } } - /** Access to the virtual root. */ + /** + * Get the root command node of this tree. + * @return the root node, which has no label + */ public CommandNode getRoot() { return root; } From e10a4ce8286a8e43c8637c58475e2a24e72983c9 Mon Sep 17 00:00:00 2001 From: Traqueur_ Date: Tue, 8 Jul 2025 09:30:14 +0200 Subject: [PATCH 3/5] fix: invoker and tabulation with new tree --- .../traqueur/commands/api/CommandManager.java | 2 +- .../commands/api/models/CommandInvoker.java | 36 ++++++++----------- .../api/models/collections/CommandTree.java | 9 ++--- 3 files changed, 20 insertions(+), 27 deletions(-) diff --git a/core/src/main/java/fr/traqueur/commands/api/CommandManager.java b/core/src/main/java/fr/traqueur/commands/api/CommandManager.java index 4ffd277..75b5731 100644 --- a/core/src/main/java/fr/traqueur/commands/api/CommandManager.java +++ b/core/src/main/java/fr/traqueur/commands/api/CommandManager.java @@ -360,8 +360,8 @@ private void addCommand(Command command, String label) throws TypeArgumentN } command.setManager(this); - commands.addCommand(label, command); this.platform.addCommand(command, label); + commands.addCommand(label, command); this.addCompletionsForLabel(labelParts); this.addCompletionForArgs(label, labelSize, args); diff --git a/core/src/main/java/fr/traqueur/commands/api/models/CommandInvoker.java b/core/src/main/java/fr/traqueur/commands/api/models/CommandInvoker.java index 546462d..27137c2 100644 --- a/core/src/main/java/fr/traqueur/commands/api/models/CommandInvoker.java +++ b/core/src/main/java/fr/traqueur/commands/api/models/CommandInvoker.java @@ -106,32 +106,24 @@ public boolean invoke(S source, String base, String[] rawArgs) { * @return the list of suggestion */ public List suggest(S source, String base, String[] args) { - for (int i = args.length; i >= 0; i--) { - String label = buildLabel(base, args, i); - Map> map = manager.getCompleters().get(label); - if (map != null && map.containsKey(args.length)) { - return map.get(args.length) - .onCompletion(source, Collections.singletonList(buildArgsBefore(base, args))) - .stream() - .filter(opt -> allowedSuggestion(source, label, opt)) - .filter(opt -> matchesPrefix(opt, args[args.length - 1])) - .collect(Collectors.toList()); - } + Optional> found = manager.getCommands().findNode(base, args); + if (!found.isPresent()) return Collections.emptyList(); + MatchResult result = found.get(); + CommandTree.CommandNode node = result.node; + String[] rawArgs = result.args; + String label = node.getFullLabel() != null ? node.getFullLabel() : base; + Map> map = manager.getCompleters().get(label); + if (map != null && map.containsKey(args.length)) { + return map.get(args.length) + .onCompletion(source, Arrays.asList(rawArgs)) + .stream() + .filter(opt -> allowedSuggestion(source, label, opt)) + .filter(opt -> matchesPrefix(opt, args[args.length - 1])) + .collect(Collectors.toList()); } return Collections.emptyList(); } - private String buildLabel(String base, String[] args, int count) { - StringBuilder sb = new StringBuilder(base.toLowerCase()); - for (int i = 0; i < count; i++) sb.append('.').append(args[i].toLowerCase()); - return sb.toString(); - } - - private String buildArgsBefore(String base, String[] args) { - if (args.length <= 1) return base; - return base + "." + String.join(".", Arrays.copyOf(args, args.length - 1)); - } - private boolean matchesPrefix(String candidate, String current) { String lower = current.toLowerCase(); return candidate.equalsIgnoreCase(current) || candidate.toLowerCase().startsWith(lower); diff --git a/core/src/main/java/fr/traqueur/commands/api/models/collections/CommandTree.java b/core/src/main/java/fr/traqueur/commands/api/models/collections/CommandTree.java index ed3bbb7..a980b8e 100644 --- a/core/src/main/java/fr/traqueur/commands/api/models/collections/CommandTree.java +++ b/core/src/main/java/fr/traqueur/commands/api/models/collections/CommandTree.java @@ -136,16 +136,17 @@ public Optional> findNode(String base, String[] rawArgs) { while (i < rawArgs.length) { String seg = rawArgs[i].toLowerCase(); CommandNode child = node.children.get(seg); + if (child != null) { node = child; i++; - } else if (node.hadChildren) { - // expected a subcommand but not found - return Optional.empty(); - } else { + } else if (node.getCommand().isPresent()) { break; + } else { + return Optional.empty(); } } + String[] left = Arrays.copyOfRange(rawArgs, i, rawArgs.length); return Optional.of(new MatchResult<>(node, left)); } From a82c1ab82d560b063a4b0c692ba789b1d4c894e8 Mon Sep 17 00:00:00 2001 From: Traqueur_ Date: Tue, 8 Jul 2025 09:59:26 +0200 Subject: [PATCH 4/5] fix(tree): fix logic to keep parent command when no child exist --- build.gradle | 9 +--- .../api/models/collections/CommandTree.java | 46 ++++++++++++------- .../models/collections/CommandTreeTest.java | 27 +++++++++-- 3 files changed, 54 insertions(+), 28 deletions(-) diff --git a/build.gradle b/build.gradle index e5ce898..eb10988 100644 --- a/build.gradle +++ b/build.gradle @@ -1,7 +1,3 @@ -plugins { - id("com.adarshr.test-logger") version "4.0.0" -} - allprojects { group = 'fr.traqueur.commands' version = property('version') @@ -25,8 +21,6 @@ allprojects { subprojects { if (!project.name.contains('test-plugin')) { - apply plugin: 'com.adarshr.test-logger' - dependencies { testImplementation 'org.junit.jupiter:junit-jupiter:5.10.0' testImplementation 'org.mockito:mockito-core:5.3.1' @@ -40,7 +34,8 @@ subprojects { } testLogging { - events("passed", "skipped", "failed") + showStandardStreams = true + events("passed", "skipped", "failed", "standardOut", "standardError") exceptionFormat "full" } } diff --git a/core/src/main/java/fr/traqueur/commands/api/models/collections/CommandTree.java b/core/src/main/java/fr/traqueur/commands/api/models/collections/CommandTree.java index a980b8e..33a6c4b 100644 --- a/core/src/main/java/fr/traqueur/commands/api/models/collections/CommandTree.java +++ b/core/src/main/java/fr/traqueur/commands/api/models/collections/CommandTree.java @@ -136,17 +136,17 @@ public Optional> findNode(String base, String[] rawArgs) { while (i < rawArgs.length) { String seg = rawArgs[i].toLowerCase(); CommandNode child = node.children.get(seg); - if (child != null) { node = child; i++; - } else if (node.getCommand().isPresent()) { + } else if (node.hadChildren) { + return Optional.empty(); + } else if (node.command != null) { break; } else { return Optional.empty(); } } - String[] left = Arrays.copyOfRange(rawArgs, i, rawArgs.length); return Optional.of(new MatchResult<>(node, left)); } @@ -173,22 +173,36 @@ public Optional> findNode(String[] segments) { * @param prune if true, remove entire subtree; otherwise just clear the command at that node */ public void removeCommand(String label, boolean prune) { - String[] parts = label.split("\\."); - CommandNode node = root; - for (String seg : parts) { - node = node.children.get(seg.toLowerCase()); - if (node == null) return; + CommandNode target = this.findNode(label.split("\\.")).map(result -> result.node).orElse(null); + if (target == null) return; + + if (prune) { + pruneSubtree(target); + } else { + clearOrPruneEmpty(target); } - CommandNode parent = node.parent; - if (parent == null) return; // cannot remove root + } - boolean hasChildren = !node.children.isEmpty(); - if (prune || !hasChildren) { - // remove this node and entire subtree + private void pruneSubtree(CommandNode node) { + CommandNode parent = node.parent; + if (parent != null) { parent.children.remove(node.label); - } else { - // clear only the command, keep subtree intact - node.command = null; + if(parent.children.isEmpty()){ + parent.hadChildren = false; + } + } + } + + private void clearOrPruneEmpty(CommandNode node) { + node.command = null; + if (node.children.isEmpty()) { + CommandNode parent = node.parent; + if (parent != null) { + parent.children.remove(node.label); + if(parent.children.isEmpty()){ + parent.hadChildren = false; + } + } } } diff --git a/core/src/test/java/fr/traqueur/commands/api/models/collections/CommandTreeTest.java b/core/src/test/java/fr/traqueur/commands/api/models/collections/CommandTreeTest.java index cb7f0c8..4f5dcc2 100644 --- a/core/src/test/java/fr/traqueur/commands/api/models/collections/CommandTreeTest.java +++ b/core/src/test/java/fr/traqueur/commands/api/models/collections/CommandTreeTest.java @@ -76,7 +76,6 @@ void testFindNonexistent() { @Test void testRemoveCommandClearOnly() { tree.addCommand("root",rootCmd); - // remove without subcommands flag false, but no children => pruned tree.removeCommand("root", false); Optional> m = tree.findNode("root", new String[]{}); assertFalse(m.isPresent()); @@ -112,13 +111,31 @@ void testRemoveCommandPruneBranch() { // remove entire branch tree.removeCommand("root.sub", true); - // sub and subsub removed - assertFalse(tree.findNode("root", new String[]{"sub"}).isPresent()); - assertFalse(tree.findNode("root", new String[]{"sub","subsub"}).isPresent()); - // root remains + Optional> rootopt = tree.findNode("root", new String[]{"sub"}); + assertTrue(rootopt.isPresent()); + assertEquals(rootCmd, rootopt.get().node.getCommand().orElse(null)); + rootopt = tree.findNode("root", new String[]{"sub", "subsub"}); + assertTrue(rootopt.isPresent()); + assertEquals(rootCmd, rootopt.get().node.getCommand().orElse(null)); assertTrue(tree.findNode("root", new String[]{}).isPresent()); } + @Test + void testRemoveCommandPruneBranchWithoutRoot() { + rootCmd.addSubCommand(subCmd); + subCmd.addSubCommand(subSubCmd); + tree.addCommand("root.sub",subCmd); + tree.addCommand("root.sub.subsub",subSubCmd); + + // remove entire branch + tree.removeCommand("root.sub", true); + + Optional> opt = tree.findNode("root", new String[]{"sub"}); + assertFalse(opt.isPresent(), "Expected no command at 'root.sub' after pruning"); + opt = tree.findNode("root", new String[]{"sub", "subsub"}); + assertFalse(opt.isPresent(), "Expected no command at 'root.sub.subsub' after pruning"); + } + // stub Command to use in tests static class StubCommand extends Command { public StubCommand(String name) { super(null, name); } From aed1d270dda26425b8e3fad90692e4d637d9662e Mon Sep 17 00:00:00 2001 From: Traqueur_ Date: Tue, 8 Jul 2025 10:04:32 +0200 Subject: [PATCH 5/5] fix(tree): return the parent command if args is not sub command and parent have command --- .../traqueur/commands/api/models/collections/CommandTree.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/src/main/java/fr/traqueur/commands/api/models/collections/CommandTree.java b/core/src/main/java/fr/traqueur/commands/api/models/collections/CommandTree.java index 33a6c4b..64d0964 100644 --- a/core/src/main/java/fr/traqueur/commands/api/models/collections/CommandTree.java +++ b/core/src/main/java/fr/traqueur/commands/api/models/collections/CommandTree.java @@ -139,7 +139,7 @@ public Optional> findNode(String base, String[] rawArgs) { if (child != null) { node = child; i++; - } else if (node.hadChildren) { + } else if (node.hadChildren && node.command == null) { return Optional.empty(); } else if (node.command != null) { break;