diff --git a/jmh-benchmarks/src/jmh/java/net/minestom/server/command/CommandBenchmark.java b/jmh-benchmarks/src/jmh/java/net/minestom/server/command/CommandBenchmark.java new file mode 100644 index 000000000..27faee7ed --- /dev/null +++ b/jmh-benchmarks/src/jmh/java/net/minestom/server/command/CommandBenchmark.java @@ -0,0 +1,105 @@ +package net.minestom.server.command; + +import net.minestom.server.command.builder.Command; +import org.openjdk.jmh.annotations.*; +import org.openjdk.jmh.infra.Blackhole; + +import java.lang.String; +import java.util.Set; +import java.util.concurrent.TimeUnit; +import java.util.function.Function; + +import static net.minestom.server.command.builder.arguments.ArgumentType.Double; +import static net.minestom.server.command.builder.arguments.ArgumentType.Float; +import static net.minestom.server.command.builder.arguments.ArgumentType.Integer; +import static net.minestom.server.command.builder.arguments.ArgumentType.Long; +import static net.minestom.server.command.builder.arguments.ArgumentType.*; + +@BenchmarkMode(Mode.AverageTime) +@State(Scope.Benchmark) +@OutputTimeUnit(TimeUnit.NANOSECONDS) +@Fork(1) +@Warmup(time = 2, iterations = 3) +@Measurement(time = 6) +public class CommandBenchmark { + Function parser; + + @Setup + public void setup() { + var graph = Graph.merge(Set.of( + new Command("tp", "teleport") {{ + addSyntax((sender, context) -> {}, Potion("pos")); + addSyntax((sender, context) -> {}, Entity("entity"), Potion("pos")); + }}, + new Command("setblock", "set") {{ + addSyntax((sender, context) -> {}, RelativeBlockPosition("pos"), BlockState("block")); + }}, + new Command("foo") {{ + setCondition((sender, commandString) -> true); + addSubcommand(new Command("bar") {{ + addConditionalSyntax((sender, commandString) -> true, (sender, context) -> {}); + }}); + addSubcommand(new Command("baz"){{ + addSyntax((sender, context) -> {}, Word("A").from("a", "b", "c"), Word("B").from("a", "b", "c")); + }}); + }}, + new Command("def") {{ + addSyntax((sender, context) -> {}, Literal("a"), Literal("b"), Literal("c"), Literal("d"), + Literal("e"), Literal("f")); + setDefaultExecutor((sender, context) -> {}); + }}, + new Command("parse") {{ + addSyntax((sender, context) -> {}, Literal("int"), Integer("val")); + addSyntax((sender, context) -> {}, Literal("double"), Double("val")); + addSyntax((sender, context) -> {}, Literal("float"), Float("val")); + addSyntax((sender, context) -> {}, Literal("long"), Long("val")); + }} + )); + final CommandParser commandParser = CommandParser.parser(); + this.parser = input -> commandParser.parse(graph, input); + } + + @Benchmark + public void unknownCommand5Char(Blackhole bh) { + bh.consume(parser.apply("01234")); + } + + @Benchmark + public void unknownCommand50Char(Blackhole bh) { + bh.consume(parser.apply("01234567890123456789012345678901234567890123456789")); + } + + @Benchmark + public void validCommandWithValidLiteral(Blackhole bh) { + bh.consume(parser.apply("foo bar")); + } + + @Benchmark + public void validCommandWithInvalid50CharLiteral(Blackhole bh) { + bh.consume(parser.apply("foo 01234567890123456789012345678901234567890123456789")); + } + + @Benchmark + public void numberParsing3Digit(Blackhole bh) { + bh.consume(parser.apply("parse int 123")); + bh.consume(parser.apply("parse float 123")); + bh.consume(parser.apply("parse double 123")); + bh.consume(parser.apply("parse long 123")); + } + + @Benchmark + public void numberParsing10Digit(Blackhole bh) { + bh.consume(parser.apply("parse int 1234567890")); + bh.consume(parser.apply("parse float 1234567890")); + bh.consume(parser.apply("parse double 1234567890")); + bh.consume(parser.apply("parse long 1234567890")); + } + + @Benchmark + public void numberParsing10DigitInvalid(Blackhole bh) { + bh.consume(parser.apply("parse int a1234567890")); + bh.consume(parser.apply("parse float a1234567890")); + bh.consume(parser.apply("parse double a1234567890")); + bh.consume(parser.apply("parse long a1234567890")); + } +} diff --git a/src/main/java/net/minestom/server/command/CommandManager.java b/src/main/java/net/minestom/server/command/CommandManager.java index fa153c0d5..749194a08 100644 --- a/src/main/java/net/minestom/server/command/CommandManager.java +++ b/src/main/java/net/minestom/server/command/CommandManager.java @@ -3,6 +3,7 @@ package net.minestom.server.command; import net.minestom.server.command.builder.Command; import net.minestom.server.command.builder.CommandDispatcher; import net.minestom.server.command.builder.CommandResult; +import net.minestom.server.command.builder.ParsedCommand; import net.minestom.server.entity.Player; import net.minestom.server.event.EventDispatcher; import net.minestom.server.event.player.PlayerCommandEvent; @@ -12,6 +13,8 @@ import net.minestom.server.utils.validate.Check; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +import java.util.*; + /** * Manager used to register {@link Command commands}. *

@@ -23,8 +26,10 @@ public final class CommandManager { private final ServerSender serverSender = new ServerSender(); private final ConsoleSender consoleSender = new ConsoleSender(); - - private final CommandDispatcher dispatcher = new CommandDispatcher(); + private final CommandParser parser = CommandParser.parser(); + private final CommandDispatcher dispatcher = new CommandDispatcher(this); + private final Map commandMap = new HashMap<>(); + private final Set commands = new HashSet<>(); private CommandCallback unknownCommandCallback; @@ -46,7 +51,10 @@ public final class CommandManager { "A command with the name " + alias + " is already registered!"); } } - this.dispatcher.register(command); + commands.add(command); + for (String name : command.getNames()) { + commandMap.put(name, command); + } } /** @@ -56,7 +64,10 @@ public final class CommandManager { * @param command the command to remove */ public void unregister(@NotNull Command command) { - this.dispatcher.unregister(command); + commands.remove(command); + for (String name : command.getNames()) { + commandMap.remove(name); + } } /** @@ -66,7 +77,7 @@ public final class CommandManager { * @return the command associated with the name, null if not any */ public @Nullable Command getCommand(@NotNull String commandName) { - return dispatcher.findCommand(commandName); + return commandMap.get(commandName.toLowerCase(Locale.ROOT)); } /** @@ -76,8 +87,7 @@ public final class CommandManager { * @return true if the command does exist */ public boolean commandExists(@NotNull String commandName) { - commandName = commandName.toLowerCase(); - return dispatcher.findCommand(commandName) != null; + return getCommand(commandName) != null; } /** @@ -88,6 +98,7 @@ public final class CommandManager { * @return the execution result */ public @NotNull CommandResult execute(@NotNull CommandSender sender, @NotNull String command) { + command = command.trim(); // Command event if (sender instanceof Player player) { PlayerCommandEvent playerCommandEvent = new PlayerCommandEvent(player, command); @@ -97,7 +108,10 @@ public final class CommandManager { command = playerCommandEvent.getCommand(); } // Process the command - final CommandResult result = dispatcher.execute(sender, command); + final CommandParser.Result parsedCommand = parseCommand(command); + final ExecutableCommand executable = parsedCommand.executable(); + final ExecutableCommand.Result executeResult = executable.execute(sender); + final CommandResult result = resultConverter(executable, executeResult, command); if (result.getType() == CommandResult.Type.UNKNOWN) { if (unknownCommandCallback != null) { this.unknownCommandCallback.apply(sender, command); @@ -157,7 +171,36 @@ public final class CommandManager { * @return the {@link DeclareCommandsPacket} for {@code player} */ public @NotNull DeclareCommandsPacket createDeclareCommandsPacket(@NotNull Player player) { - final Graph merged = Graph.merge(dispatcher.getCommands()); - return GraphConverter.createPacket(merged, player); + return GraphConverter.createPacket(getGraph(), player); + } + + public @NotNull Set<@NotNull Command> getCommands() { + return Collections.unmodifiableSet(commands); + } + + /** + * Parses the command based on the registered commands + * + * @param input commands string without prefix + * @return the parsing result + */ + public CommandParser.Result parseCommand(String input) { + return parser.parse(getGraph(), input); + } + + private Graph getGraph() { + //todo cache + return Graph.merge(commands); + } + + private static CommandResult resultConverter(ExecutableCommand executable, + ExecutableCommand.Result newResult, + String input) { + return CommandResult.of(switch (newResult.type()) { + case SUCCESS -> CommandResult.Type.SUCCESS; + case CANCELLED, PRECONDITION_FAILED, EXECUTOR_EXCEPTION -> CommandResult.Type.CANCELLED; + case INVALID_SYNTAX -> CommandResult.Type.INVALID_SYNTAX; + case UNKNOWN -> CommandResult.Type.UNKNOWN; + }, input, ParsedCommand.fromExecutable(executable), newResult.commandData()); } } diff --git a/src/main/java/net/minestom/server/command/CommandParser.java b/src/main/java/net/minestom/server/command/CommandParser.java new file mode 100644 index 000000000..78f8e5ac6 --- /dev/null +++ b/src/main/java/net/minestom/server/command/CommandParser.java @@ -0,0 +1,54 @@ +package net.minestom.server.command; + +import net.minestom.server.command.builder.arguments.Argument; +import net.minestom.server.command.builder.suggestion.Suggestion; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.Contract; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.List; + +@ApiStatus.Internal +@ApiStatus.Experimental +public interface CommandParser { + static @NotNull CommandParser parser() { + return CommandParserImpl.PARSER; + } + + /** + * Parses the command by following the graph + * + * @param graph structure to use for parsing + * @param input command string without prefix + * @return the parsed command which can be executed and cached + */ + @Contract("_, _ -> new") + @NotNull Result parse(@NotNull Graph graph, @NotNull String input); + + sealed interface Result { + @NotNull ExecutableCommand executable(); + + @ApiStatus.Internal + @Nullable Suggestion suggestion(CommandSender sender); + + @ApiStatus.Internal + List> args(); + + sealed interface UnknownCommand extends Result + permits CommandParserImpl.UnknownCommandResult { + } + + sealed interface KnownCommand extends Result + permits CommandParserImpl.InternalKnownCommand, Result.KnownCommand.Invalid, Result.KnownCommand.Valid { + + sealed interface Valid extends KnownCommand + permits CommandParserImpl.ValidCommand { + } + + sealed interface Invalid extends KnownCommand + permits CommandParserImpl.InvalidCommand { + } + } + } +} diff --git a/src/main/java/net/minestom/server/command/CommandParserImpl.java b/src/main/java/net/minestom/server/command/CommandParserImpl.java new file mode 100644 index 000000000..007ebd65d --- /dev/null +++ b/src/main/java/net/minestom/server/command/CommandParserImpl.java @@ -0,0 +1,423 @@ +package net.minestom.server.command; + +import net.minestom.server.command.Graph.Node; +import net.minestom.server.command.builder.ArgumentCallback; +import net.minestom.server.command.builder.CommandContext; +import net.minestom.server.command.builder.CommandData; +import net.minestom.server.command.builder.CommandExecutor; +import net.minestom.server.command.builder.arguments.Argument; +import net.minestom.server.command.builder.condition.CommandCondition; +import net.minestom.server.command.builder.exception.ArgumentSyntaxException; +import net.minestom.server.command.builder.suggestion.Suggestion; +import net.minestom.server.command.builder.suggestion.SuggestionCallback; +import org.jetbrains.annotations.Contract; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.function.Supplier; +import java.util.stream.Collectors; + +final class CommandParserImpl implements CommandParser { + private static final Logger LOGGER = LoggerFactory.getLogger(CommandParserImpl.class); + static final CommandParserImpl PARSER = new CommandParserImpl(); + + static final class Chain { + CommandExecutor defaultExecutor = null; + final ArrayDeque nodeResults = new ArrayDeque<>(); + final List conditions = new ArrayList<>(); + final List globalListeners = new ArrayList<>(); + + void append(NodeResult result) { + this.nodeResults.add(result); + final Graph.Execution execution = result.node.execution(); + if (execution != null) { + // Create condition chain + final CommandCondition condition = execution.condition(); + if (condition != null) conditions.add(condition); + // Track default executor + final CommandExecutor defExec = execution.defaultExecutor(); + if (defExec != null) defaultExecutor = defExec; + // Merge global listeners + final CommandExecutor globalListener = execution.globalListener(); + if (globalListener != null) globalListeners.add(globalListener); + } + } + + CommandCondition mergedConditions() { + return (sender, commandString) -> { + for (CommandCondition condition : conditions) { + if (!condition.canUse(sender, commandString)) return false; + } + return true; + }; + } + + CommandExecutor mergedGlobalExecutors() { + return (sender, context) -> globalListeners.forEach(x -> x.apply(sender, context)); + } + + SuggestionCallback extractSuggestionCallback() { + return nodeResults.peekLast().callback; + } + + Map> collectArguments() { + return nodeResults.stream() + .skip(1) // skip root + .collect(Collectors.toUnmodifiableMap(NodeResult::name, NodeResult::argumentResult)); + } + + List> getArgs() { + return nodeResults.stream().map(x -> x.node.argument()).collect(Collectors.toList()); + } + } + + @Override + public @NotNull CommandParser.Result parse(@NotNull Graph graph, @NotNull String input) { + final CommandStringReader reader = new CommandStringReader(input); + final Chain chain = new Chain(); + // Read from input + NodeResult result; + Node parent = graph.root(); + while ((result = parseChild(parent, reader)) != null) { + chain.append(result); + if (result.argumentResult instanceof ArgumentResult.SyntaxError e) { + // Syntax error stop at this arg + final ArgumentCallback argumentCallback = parent.argument().getCallback(); + if (argumentCallback == null && chain.defaultExecutor != null) { + return ValidCommand.defaultExecutor(input, chain); + } else { + return new InvalidCommand(input, chain.mergedConditions(), + argumentCallback, e, chain.collectArguments(), chain.mergedGlobalExecutors(), + chain.extractSuggestionCallback(), chain.getArgs()); + } + } + parent = result.node; + } + // Check children for arguments with default values + do { + Node tmp = parent; + parent = null; + for (Node child : tmp.next()) { + final Argument argument = child.argument(); + final Supplier defaultSupplier = argument.getDefaultValue(); + if (defaultSupplier != null) { + final Object value = defaultSupplier.get(); + final ArgumentResult argumentResult = new ArgumentResult.Success<>(value, ""); + chain.append(new NodeResult(child, argumentResult, argument.getSuggestionCallback())); + parent = child; + break; + } + } + } while (parent != null); + // Check if any syntax has been found + final NodeResult lastNode = chain.nodeResults.peekLast(); + if (lastNode == null) return UnknownCommandResult.INSTANCE; + // Verify syntax(s) + final CommandExecutor executor = nullSafeGetter(lastNode.node().execution(), Graph.Execution::executor); + if (executor == null) { + // Syntax error + if (chain.defaultExecutor != null) { + return ValidCommand.defaultExecutor(input, chain); + } else { + return InvalidCommand.invalid(input, chain); + } + } + if (reader.hasRemaining()) { + // Command had trailing data + if (chain.defaultExecutor != null) { + return ValidCommand.defaultExecutor(input, chain); + } else { + return InvalidCommand.invalid(input, chain); + } + } + return ValidCommand.executor(input, chain, executor); + } + + @Contract("null, _ -> null; !null, null -> fail; !null, !null -> _") + private static @Nullable R nullSafeGetter(@Nullable T obj, Function getter) { + return obj == null ? null : getter.apply(obj); + } + + private static NodeResult parseChild(Node parent, CommandStringReader reader) { + if (!reader.hasRemaining()) return null; + for (Node child : parent.next()) { + final Argument argument = child.argument(); + final int start = reader.cursor(); + final ArgumentResult parse = parse(argument, reader); + if (parse instanceof ArgumentResult.Success success) { + return new NodeResult(child, (ArgumentResult) success, + argument.getSuggestionCallback()); + } else if (parse instanceof ArgumentResult.SyntaxError syntaxError) { + return new NodeResult(child, (ArgumentResult) syntaxError, + argument.getSuggestionCallback()); + } else { + // Reset cursor & try next + reader.cursor(start); + } + } + for (Node node : parent.next()) { + final SuggestionCallback suggestionCallback = node.argument().getSuggestionCallback(); + if (suggestionCallback != null) { + return new NodeResult(parent, + new ArgumentResult.SyntaxError<>("None of the arguments were compatible, but a suggestion callback was found.", "", -1), + suggestionCallback); + } + } + return null; + } + + record UnknownCommandResult() implements Result.UnknownCommand { + private static final Result INSTANCE = new UnknownCommandResult(); + + @Override + public @NotNull ExecutableCommand executable() { + return UnknownExecutableCmd.INSTANCE; + } + + @Override + public @Nullable Suggestion suggestion(CommandSender sender) { + return null; + } + + @Override + public List> args() { + return null; + } + } + + sealed interface InternalKnownCommand extends Result.KnownCommand { + String input(); + + @Nullable CommandCondition condition(); + + @NotNull Map> arguments(); + + CommandExecutor globalListener(); + + @Nullable SuggestionCallback suggestionCallback(); + + @Override + default @Nullable Suggestion suggestion(CommandSender sender) { + final SuggestionCallback callback = suggestionCallback(); + if (callback == null) return null; + final int lastSpace = input().lastIndexOf(" "); + final Suggestion suggestion = new Suggestion(input(), lastSpace + 2, input().length() - lastSpace - 1); + final CommandContext context = createCommandContext(input(), arguments()); + callback.apply(sender, context, suggestion); + return suggestion; + } + } + + record InvalidCommand(String input, CommandCondition condition, ArgumentCallback callback, + ArgumentResult.SyntaxError error, + @NotNull Map> arguments, CommandExecutor globalListener, + @Nullable SuggestionCallback suggestionCallback, List> args) + implements InternalKnownCommand, Result.KnownCommand.Invalid { + + static InvalidCommand invalid(String input, Chain chain) { + return new InvalidCommand(input, chain.mergedConditions(), + null/*todo command syntax callback*/, + new ArgumentResult.SyntaxError<>("Command has trailing data.", null, -1), + chain.collectArguments(), chain.mergedGlobalExecutors(), chain.extractSuggestionCallback(), chain.getArgs()); + } + + @Override + public @NotNull ExecutableCommand executable() { + return new InvalidExecutableCmd(condition, globalListener, callback, error, input, arguments); + } + } + + record ValidCommand(String input, CommandCondition condition, CommandExecutor executor, + @NotNull Map> arguments, + CommandExecutor globalListener, @Nullable SuggestionCallback suggestionCallback, List> args) + implements InternalKnownCommand, Result.KnownCommand.Valid { + + static ValidCommand defaultExecutor(String input, Chain chain) { + return new ValidCommand(input, chain.mergedConditions(), chain.defaultExecutor, chain.collectArguments(), + chain.mergedGlobalExecutors(), chain.extractSuggestionCallback(), chain.getArgs()); + } + + static ValidCommand executor(String input, Chain chain, CommandExecutor executor) { + return new ValidCommand(input, chain.mergedConditions(), executor, chain.collectArguments(), chain.mergedGlobalExecutors(), + chain.extractSuggestionCallback(), chain.getArgs()); + } + + @Override + public @NotNull ExecutableCommand executable() { + return new ValidExecutableCmd(condition, globalListener, executor, input, arguments); + } + } + + record UnknownExecutableCmd() implements ExecutableCommand { + static final ExecutableCommand INSTANCE = new UnknownExecutableCmd(); + + @Override + public @NotNull Result execute(@NotNull CommandSender sender) { + return ExecutionResultImpl.UNKNOWN; + } + } + + record ValidExecutableCmd(CommandCondition condition, CommandExecutor globalListener, CommandExecutor executor, + String input, + Map> arguments) implements ExecutableCommand { + @Override + public @NotNull Result execute(@NotNull CommandSender sender) { + final CommandContext context = createCommandContext(input, arguments); + + globalListener().apply(sender, context); + + if (condition != null && !condition.canUse(sender, input())) { + return ExecutionResultImpl.PRECONDITION_FAILED; + } + try { + executor().apply(sender, context); + return new ExecutionResultImpl(ExecutableCommand.Result.Type.SUCCESS, context.getReturnData()); + } catch (Exception e) { + LOGGER.error("An exception was encountered while executing command: " + input(), e); + return ExecutionResultImpl.EXECUTOR_EXCEPTION; + } + } + } + + record InvalidExecutableCmd(CommandCondition condition, CommandExecutor globalListener, ArgumentCallback callback, + ArgumentResult.SyntaxError error, String input, + Map> arguments) implements ExecutableCommand { + @Override + public @NotNull Result execute(@NotNull CommandSender sender) { + globalListener().apply(sender, createCommandContext(input, arguments)); + + if (condition != null && !condition.canUse(sender, input())) { + return ExecutionResultImpl.PRECONDITION_FAILED; + } + if (callback != null) + callback.apply(sender, new ArgumentSyntaxException(error.message(), error.input(), error.code())); + return ExecutionResultImpl.INVALID_SYNTAX; + } + } + + private static CommandContext createCommandContext(String input, Map> arguments) { + final CommandContext context = new CommandContext(input); + for (var entry : arguments.entrySet()) { + final String identifier = entry.getKey(); + final ArgumentResult value = entry.getValue(); + + final Object argOutput = value instanceof ArgumentResult.Success success ? success.value() : null; + final String argInput = value instanceof ArgumentResult.Success success ? success.input() : ""; + + context.setArg(identifier, argOutput, argInput); + } + return context; + } + + record ExecutionResultImpl(Type type, CommandData commandData) implements ExecutableCommand.Result { + static final ExecutableCommand.Result CANCELLED = new ExecutionResultImpl(Type.CANCELLED, null); + static final ExecutableCommand.Result UNKNOWN = new ExecutionResultImpl(Type.UNKNOWN, null); + static final ExecutableCommand.Result EXECUTOR_EXCEPTION = new ExecutionResultImpl(Type.EXECUTOR_EXCEPTION, null); + static final ExecutableCommand.Result PRECONDITION_FAILED = new ExecutionResultImpl(Type.PRECONDITION_FAILED, null); + static final ExecutableCommand.Result INVALID_SYNTAX = new ExecutionResultImpl(Type.INVALID_SYNTAX, null); + } + + private record NodeResult(Node node, ArgumentResult argumentResult, SuggestionCallback callback) { + public String name() { + return node.argument().getId(); + } + } + + static final class CommandStringReader { + private final String input; + private int cursor = 0; + + CommandStringReader(String input) { + this.input = input; + } + + boolean hasRemaining() { + return cursor < input.length(); + } + + String readWord() { + final String input = this.input; + final int cursor = this.cursor; + + final int i = input.indexOf(' ', cursor); + if (i == -1) { + this.cursor = input.length() + 1; + return input.substring(cursor); + } + final String read = input.substring(cursor, i); + this.cursor += read.length() + 1; + return read; + } + + String readRemaining() { + final String input = this.input; + final String result = input.substring(cursor); + this.cursor = input.length(); + return result; + } + + int cursor() { + return cursor; + } + + void cursor(int cursor) { + assert cursor >= 0 && cursor <= input.length(); + this.cursor = cursor; + } + } + + // ARGUMENT + + private static ArgumentResult parse(Argument argument, CommandStringReader reader) { + // Handle specific type without loop + try { + // Single word argument + if (!argument.allowSpace()) { + final String word = reader.readWord(); + return new ArgumentResult.Success<>(argument.parse(word), word); + } + // Complete input argument + if (argument.useRemaining()) { + final String remaining = reader.readRemaining(); + return new ArgumentResult.Success<>(argument.parse(remaining), remaining); + } + } catch (ArgumentSyntaxException ignored) { + return new ArgumentResult.IncompatibleType<>(); + } + // Bruteforce + assert argument.allowSpace() && !argument.useRemaining(); + StringBuilder current = new StringBuilder(reader.readWord()); + while (true) { + try { + final String input = current.toString(); + return new ArgumentResult.Success<>(argument.parse(input), input); + } catch (ArgumentSyntaxException ignored) { + if (!reader.hasRemaining()) break; + current.append(" "); + current.append(reader.readWord()); + } + } + return new ArgumentResult.IncompatibleType<>(); + } + + private sealed interface ArgumentResult { + record Success(T value, String input) + implements ArgumentResult { + } + + record IncompatibleType() + implements ArgumentResult { + } + + record SyntaxError(String message, String input, int code) + implements ArgumentResult { + } + } +} diff --git a/src/main/java/net/minestom/server/command/ExecutableCommand.java b/src/main/java/net/minestom/server/command/ExecutableCommand.java new file mode 100644 index 000000000..c0a487d80 --- /dev/null +++ b/src/main/java/net/minestom/server/command/ExecutableCommand.java @@ -0,0 +1,46 @@ +package net.minestom.server.command; + +import net.minestom.server.command.builder.Command; +import net.minestom.server.command.builder.CommandData; +import net.minestom.server.command.builder.CommandSyntax; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; + +@ApiStatus.Internal +@ApiStatus.Experimental +public interface ExecutableCommand { + @NotNull Result execute(@NotNull CommandSender sender); + + interface Result { + @NotNull Type type(); + + @NotNull CommandData commandData(); + + enum Type { + /** + * Command executed successfully. + */ + SUCCESS, + /** + * Command cancelled by an event listener. + */ + CANCELLED, + /** + * Either {@link Command#getCondition()} or {@link CommandSyntax#getCommandCondition()} failed + */ + PRECONDITION_FAILED, + /** + * Command couldn't be executed because of a syntax error + */ + INVALID_SYNTAX, + /** + * The command executor threw an exception + */ + EXECUTOR_EXCEPTION, + /** + * Unknown command + */ + UNKNOWN + } + } +} diff --git a/src/main/java/net/minestom/server/command/Graph.java b/src/main/java/net/minestom/server/command/Graph.java index 01b9e16d2..4ef1fb2ef 100644 --- a/src/main/java/net/minestom/server/command/Graph.java +++ b/src/main/java/net/minestom/server/command/Graph.java @@ -1,8 +1,11 @@ package net.minestom.server.command; import net.minestom.server.command.builder.Command; +import net.minestom.server.command.builder.CommandExecutor; import net.minestom.server.command.builder.arguments.Argument; +import net.minestom.server.command.builder.condition.CommandCondition; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.UnknownNullability; import java.util.Collection; @@ -11,8 +14,12 @@ import java.util.function.Consumer; import java.util.function.Predicate; sealed interface Graph permits GraphImpl { + static @NotNull Builder builder(@NotNull Argument argument, @Nullable Execution execution) { + return new GraphImpl.BuilderImpl(argument, execution); + } + static @NotNull Builder builder(@NotNull Argument argument) { - return new GraphImpl.BuilderImpl(argument); + return new GraphImpl.BuilderImpl(argument, null); } static @NotNull Graph fromCommand(@NotNull Command command) { @@ -44,13 +51,35 @@ sealed interface Graph permits GraphImpl { } sealed interface Execution extends Predicate permits GraphImpl.ExecutionImpl { - // TODO execute the node + @UnknownNullability CommandExecutor defaultExecutor(); + + @UnknownNullability CommandExecutor globalListener(); + + /** + * Non-null if the command at this point considered executable, must be present + * on the last node of the syntax. + */ + @Nullable CommandExecutor executor(); + + /** + * Non-null if the command or syntax has a condition, must be present + * only on nodes that specify it + */ + @Nullable CommandCondition condition(); } sealed interface Builder permits GraphImpl.BuilderImpl { - @NotNull Builder append(@NotNull Argument argument, @NotNull Consumer consumer); + @NotNull Builder append(@NotNull Argument argument, @Nullable Execution execution, @NotNull Consumer consumer); - @NotNull Builder append(@NotNull Argument argument); + @NotNull Builder append(@NotNull Argument argument, @Nullable Execution execution); + + default @NotNull Builder append(@NotNull Argument argument, @NotNull Consumer consumer) { + return append(argument, null, consumer); + } + + default @NotNull Builder append(@NotNull Argument argument) { + return append(argument, (Execution) null); + } @NotNull Graph build(); } diff --git a/src/main/java/net/minestom/server/command/GraphConverter.java b/src/main/java/net/minestom/server/command/GraphConverter.java index 9f1c37c6a..2578c62ee 100644 --- a/src/main/java/net/minestom/server/command/GraphConverter.java +++ b/src/main/java/net/minestom/server/command/GraphConverter.java @@ -6,11 +6,9 @@ import net.minestom.server.network.packet.server.play.DeclareCommandsPacket; import org.jetbrains.annotations.Contract; import org.jetbrains.annotations.Nullable; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; +import java.util.*; import java.util.concurrent.atomic.AtomicInteger; -import java.util.function.Consumer; +import java.util.function.BiConsumer; final class GraphConverter { private GraphConverter() { @@ -20,18 +18,19 @@ final class GraphConverter { @Contract("_, _ -> new") public static DeclareCommandsPacket createPacket(Graph graph, @Nullable Player player) { List nodes = new ArrayList<>(); - List> rootRedirect = new ArrayList<>(); + List> redirects = new ArrayList<>(); + Map, Integer> argToPacketId = new HashMap<>(); final AtomicInteger idSource = new AtomicInteger(0); - final int rootId = append(graph.root(), nodes, rootRedirect, idSource, null, null, player)[0]; - for (var i : rootRedirect) { - i.accept(rootId); + final int rootId = append(graph.root(), nodes, redirects, idSource, null, player, argToPacketId)[0]; + for (var r : redirects) { + r.accept(graph, rootId); } return new DeclareCommandsPacket(nodes, rootId); } private static int[] append(Graph.Node graphNode, List to, - List> rootRedirect, AtomicInteger id, @Nullable AtomicInteger redirect, - List redirectSetters, @Nullable Player player) { + List> redirects, AtomicInteger id, @Nullable AtomicInteger redirect, + @Nullable Player player, Map, Integer> argToPacketId) { final Graph.Execution execution = graphNode.execution(); if (player != null && execution != null) { if (!execution.test(player)) return new int[0]; @@ -43,7 +42,10 @@ final class GraphConverter { final DeclareCommandsPacket.Node node = new DeclareCommandsPacket.Node(); int[] packetNodeChildren = new int[children.size()]; for (int i = 0, appendIndex = 0; i < children.size(); i++) { - final int[] append = append(children.get(i), to, rootRedirect, id, redirect, redirectSetters, player); + final int[] append = append(children.get(i), to, redirects, id, redirect, player, argToPacketId); + if (append.length > 0) { + argToPacketId.put(children.get(i).argument(), append[0]); + } if (append.length == 1) { packetNodeChildren[appendIndex++] = append[0]; } else { @@ -61,21 +63,36 @@ final class GraphConverter { node.name = argument.getId(); if (redirect != null) { node.flags |= 0x8; - redirectSetters.add(() -> node.redirectedNode = redirect.get()); + redirects.add((graph, root) -> node.redirectedNode = redirect.get()); } } to.add(node); return new int[]{id.getAndIncrement()}; } else { - if (argument instanceof ArgumentCommand) { + if (argument instanceof ArgumentCommand argCmd) { node.flags = literal(false, true); node.name = argument.getId(); - rootRedirect.add(i -> node.redirectedNode = i); + final String shortcut = argCmd.getShortcut(); + if (shortcut.isEmpty()) { + redirects.add((graph, root) -> node.redirectedNode = root); + } else { + redirects.add((graph, root) -> { + final List> args = CommandParser.parser().parse(graph, shortcut).args(); + final Argument last = args.get(args.size() - 1); + if (last.allowSpace()) { + node.redirectedNode = argToPacketId.get(args.get(args.size()-2)); + } else { + node.redirectedNode = argToPacketId.get(last); + } + }); + } to.add(node); return new int[]{id.getAndIncrement()}; } else if (argument instanceof ArgumentEnum || (argument instanceof ArgumentWord word && word.hasRestrictions())) { - List entries = argument instanceof ArgumentEnum ? ((ArgumentEnum) argument).entries() : Arrays.stream(((ArgumentWord) argument).getRestrictions()).toList(); + List entries = argument instanceof ArgumentEnum ? + ((ArgumentEnum) argument).entries() : + Arrays.stream(((ArgumentWord) argument).getRestrictions()).toList(); final int[] res = new int[entries.size()]; for (int i = 0; i < res.length; i++) { String entry = entries.get(i); @@ -85,7 +102,7 @@ final class GraphConverter { subNode.name = entry; if (redirect != null) { subNode.flags |= 0x8; - redirectSetters.add(() -> subNode.redirectedNode = redirect.get()); + redirects.add((graph, root) -> subNode.redirectedNode = redirect.get()); } to.add(subNode); res[i] = id.getAndIncrement(); @@ -99,7 +116,8 @@ final class GraphConverter { Argument entry = entries.get(i); if (i == entries.size() - 1) { // Last will be the parent of next args - final int[] l = append(new GraphImpl.NodeImpl(entry, null, List.of()), to, rootRedirect, id, redirect, redirectSetters, player); + final int[] l = append(new GraphImpl.NodeImpl(entry, null, List.of()), to, redirects, + id, redirect, player, argToPacketId); for (int n : l) { to.get(n).children = node.children; } @@ -109,10 +127,12 @@ final class GraphConverter { return res == null ? l : res; } else if (i == 0) { // First will be the children & parent of following - res = append(new GraphImpl.NodeImpl(entry, null, List.of()), to, rootRedirect, id, null, redirectSetters, player); + res = append(new GraphImpl.NodeImpl(entry, null, List.of()), to, redirects, id, + null, player, argToPacketId); last = res; } else { - final int[] l = append(new GraphImpl.NodeImpl(entry, null, List.of()), to, rootRedirect, id, null, redirectSetters, player); + final int[] l = append(new GraphImpl.NodeImpl(entry, null, List.of()), to, redirects, + id, null, player, argToPacketId); for (int n : last) { to.get(n).children = l; } @@ -122,12 +142,12 @@ final class GraphConverter { throw new RuntimeException("Arg group must have child args."); } else if (argument instanceof ArgumentLoop special) { AtomicInteger r = new AtomicInteger(); - List setters = new ArrayList<>(); int[] res = new int[special.arguments().size()]; List arguments = special.arguments(); for (int i = 0, appendIndex = 0; i < arguments.size(); i++) { Object arg = arguments.get(i); - final int[] append = append(new GraphImpl.NodeImpl((Argument) arg, null, List.of()), to, rootRedirect, id, r, setters, player); + final int[] append = append(new GraphImpl.NodeImpl((Argument) arg, null, List.of()), to, + redirects, id, r, player, argToPacketId); if (append.length == 1) { res[appendIndex++] = append[0]; } else { @@ -137,16 +157,19 @@ final class GraphConverter { } } r.set(id.get()); - setters.forEach(Runnable::run); return res; } else { - node.flags = arg(false, argument.hasSuggestion()); + final boolean hasSuggestion = argument.hasSuggestion(); + node.flags = arg(false, hasSuggestion); node.name = argument.getId(); node.parser = argument.parser(); node.properties = argument.nodeProperties(); if (redirect != null) { node.flags |= 0x8; - redirectSetters.add(() -> node.redirectedNode = redirect.get()); + redirects.add((graph, root) -> node.redirectedNode = redirect.get()); + } + if (hasSuggestion) { + node.suggestionsType = argument.suggestionType().getIdentifier(); } to.add(node); return new int[]{id.getAndIncrement()}; diff --git a/src/main/java/net/minestom/server/command/GraphImpl.java b/src/main/java/net/minestom/server/command/GraphImpl.java index f30950bdd..df1733126 100644 --- a/src/main/java/net/minestom/server/command/GraphImpl.java +++ b/src/main/java/net/minestom/server/command/GraphImpl.java @@ -1,10 +1,12 @@ package net.minestom.server.command; import net.minestom.server.command.builder.Command; +import net.minestom.server.command.builder.CommandExecutor; import net.minestom.server.command.builder.CommandSyntax; import net.minestom.server.command.builder.arguments.Argument; import net.minestom.server.command.builder.condition.CommandCondition; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import java.util.*; import java.util.function.Consumer; @@ -33,22 +35,23 @@ record GraphImpl(NodeImpl root) implements Graph { return compare(root, graph.root(), comparator); } - record BuilderImpl(Argument argument, List children) implements Graph.Builder { - public BuilderImpl(Argument argument) { - this(argument, new ArrayList<>()); + record BuilderImpl(Argument argument, List children, Execution execution) implements Graph.Builder { + public BuilderImpl(Argument argument, Execution execution) { + this(argument, new ArrayList<>(), execution); } @Override - public Graph.@NotNull Builder append(@NotNull Argument argument, @NotNull Consumer consumer) { - BuilderImpl builder = new BuilderImpl(argument); + public Graph.@NotNull Builder append(@NotNull Argument argument, @Nullable Execution execution, + @NotNull Consumer consumer) { + BuilderImpl builder = new BuilderImpl(argument, execution); consumer.accept(builder); this.children.add(builder); return this; } @Override - public Graph.@NotNull Builder append(@NotNull Argument argument) { - this.children.add(new BuilderImpl(argument, List.of())); + public Graph.@NotNull Builder append(@NotNull Argument argument, @Nullable Execution execution) { + this.children.add(new BuilderImpl(argument, List.of(), execution)); return this; } @@ -63,7 +66,7 @@ record GraphImpl(NodeImpl root) implements Graph { final List children = builder.children; Node[] nodes = new NodeImpl[children.size()]; for (int i = 0; i < children.size(); i++) nodes[i] = fromBuilder(children.get(i)); - return new NodeImpl(builder.argument, null, List.of(nodes)); + return new NodeImpl(builder.argument, (ExecutionImpl) builder.execution, List.of(nodes)); } static NodeImpl command(Command command) { @@ -75,22 +78,38 @@ record GraphImpl(NodeImpl root) implements Graph { } } - record ExecutionImpl(Predicate predicate) implements Execution { + record ExecutionImpl(Predicate predicate, + CommandExecutor defaultExecutor, CommandExecutor globalListener, + CommandExecutor executor, CommandCondition condition) implements Execution { @Override public boolean test(CommandSender commandSender) { return predicate.test(commandSender); } static ExecutionImpl fromCommand(Command command) { - final CommandCondition condition = command.getCondition(); - if (condition == null) return null; - return new ExecutionImpl(commandSender -> condition.canUse(commandSender, null)); + final CommandExecutor defaultExecutor = command.getDefaultExecutor(); + final CommandCondition defaultCondition = command.getCondition(); + + CommandExecutor executor = defaultExecutor; + CommandCondition condition = defaultCondition; + for (var syntax : command.getSyntaxes()) { + if (syntax.getArguments().length == 0) { + executor = syntax.getExecutor(); + condition = syntax.getCommandCondition(); + break; + } + } + final CommandExecutor globalListener = (sender, context) -> command.globalListener(sender, context, context.getInput()); + + return new ExecutionImpl(commandSender -> defaultCondition == null || defaultCondition.canUse(commandSender, null), + defaultExecutor, globalListener, executor, condition); } static ExecutionImpl fromSyntax(CommandSyntax syntax) { + final CommandExecutor executor = syntax.getExecutor(); final CommandCondition condition = syntax.getCommandCondition(); - if (condition == null) return null; - return new ExecutionImpl(commandSender -> condition.canUse(commandSender, null)); + return new ExecutionImpl(commandSender -> condition == null || condition.canUse(commandSender, null), + null, null, executor, condition); } } diff --git a/src/main/java/net/minestom/server/command/builder/CommandDispatcher.java b/src/main/java/net/minestom/server/command/builder/CommandDispatcher.java index 4eead4825..ce5805907 100644 --- a/src/main/java/net/minestom/server/command/builder/CommandDispatcher.java +++ b/src/main/java/net/minestom/server/command/builder/CommandDispatcher.java @@ -2,33 +2,33 @@ package net.minestom.server.command.builder; import com.github.benmanes.caffeine.cache.Cache; import com.github.benmanes.caffeine.cache.Caffeine; -import it.unimi.dsi.fastutil.ints.Int2ObjectRBTreeMap; +import net.minestom.server.command.CommandManager; +import net.minestom.server.command.CommandParser; import net.minestom.server.command.CommandSender; -import net.minestom.server.command.builder.arguments.Argument; -import net.minestom.server.command.builder.exception.ArgumentSyntaxException; -import net.minestom.server.command.builder.parser.CommandParser; -import net.minestom.server.command.builder.parser.CommandQueryResult; -import net.minestom.server.command.builder.parser.CommandSuggestionHolder; -import net.minestom.server.command.builder.parser.ValidSyntaxHolder; -import net.minestom.server.utils.StringUtils; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -import java.util.*; +import java.util.Set; import java.util.concurrent.TimeUnit; /** * Class responsible for parsing {@link Command}. */ public class CommandDispatcher { - - private final Map commandMap = new HashMap<>(); - private final Set commands = new HashSet<>(); + private final CommandManager manager; private final Cache cache = Caffeine.newBuilder() .expireAfterWrite(30, TimeUnit.SECONDS) .build(); + public CommandDispatcher(CommandManager manager) { + this.manager = manager; + } + + public CommandDispatcher() { + this(new CommandManager()); + } + /** * Registers a command, * be aware that registering a command name or alias will override the previous entry. @@ -36,37 +36,15 @@ public class CommandDispatcher { * @param command the command to register */ public void register(@NotNull Command command) { - this.commandMap.put(command.getName().toLowerCase(), command); - - // Register aliases - final String[] aliases = command.getAliases(); - if (aliases != null) { - for (String alias : aliases) { - this.commandMap.put(alias.toLowerCase(), command); - } - } - - this.commands.add(command); + manager.register(command); } public void unregister(@NotNull Command command) { - this.commandMap.remove(command.getName().toLowerCase()); - - final String[] aliases = command.getAliases(); - if (aliases != null) { - for (String alias : aliases) { - this.commandMap.remove(alias.toLowerCase()); - } - } - - this.commands.remove(command); - - // Clear cache - this.cache.invalidateAll(); + manager.unregister(command); } public @NotNull Set getCommands() { - return Collections.unmodifiableSet(commands); + return manager.getCommands(); } /** @@ -76,8 +54,7 @@ public class CommandDispatcher { * @return the {@link Command} associated with the name, null if not any */ public @Nullable Command findCommand(@NotNull String commandName) { - commandName = commandName.toLowerCase(); - return commandMap.getOrDefault(commandName, null); + return manager.getCommand(commandName); } /** @@ -88,12 +65,7 @@ public class CommandDispatcher { * @return the command result */ public @NotNull CommandResult execute(@NotNull CommandSender source, @NotNull String commandString) { - CommandResult commandResult = parse(commandString); - ParsedCommand parsedCommand = commandResult.parsedCommand; - if (parsedCommand != null) { - commandResult.commandData = parsedCommand.execute(source); - } - return commandResult; + return manager.execute(source, commandString); } /** @@ -103,137 +75,21 @@ public class CommandDispatcher { * @return the parsing result */ public @NotNull CommandResult parse(@NotNull String commandString) { - commandString = commandString.trim(); - // Verify if the result is cached - { - final CommandResult cachedResult = cache.getIfPresent(commandString); - if (cachedResult != null) { - return cachedResult; - } - } - - // Split space - final String[] parts = commandString.split(StringUtils.SPACE); - final String commandName = parts[0]; - - final CommandQueryResult commandQueryResult = CommandParser.findCommand(this, commandString); - // Check if the command exists - if (commandQueryResult == null) { - return CommandResult.of(CommandResult.Type.UNKNOWN, commandName); - } - CommandResult result = new CommandResult(); - result.input = commandString; - // Find the used syntax and fill CommandResult#type and CommandResult#parsedCommand - findParsedCommand( commandQueryResult, commandName, commandString, result); - - // Cache result - this.cache.put(commandString, result); - - return result; + final net.minestom.server.command.CommandParser.Result test = manager.parseCommand(commandString); + return resultConverter(test, commandString); } - private @NotNull ParsedCommand findParsedCommand(@NotNull CommandQueryResult commandQueryResult, - @NotNull String commandName, - @NotNull String commandString, - @NotNull CommandResult result) { - final Command command = commandQueryResult.command(); - String[] args = commandQueryResult.args(); - final boolean hasArgument = args.length > 0; - - final String input = commandName + StringUtils.SPACE + String.join(StringUtils.SPACE, args); - - ParsedCommand parsedCommand = new ParsedCommand(); - parsedCommand.parents = commandQueryResult.parents(); - parsedCommand.command = command; - parsedCommand.commandString = commandString; - - // The default executor should be used if no argument is provided - if (!hasArgument) { - Optional optionalSyntax = command.getSyntaxes() - .stream() - .filter(syntax -> syntax.getArguments().length == 0) - .findFirst(); - - if (optionalSyntax.isPresent()) { - // Empty syntax found - final CommandSyntax syntax = optionalSyntax.get(); - parsedCommand.syntax = syntax; - parsedCommand.executor = syntax.getExecutor(); - parsedCommand.context = new CommandContext(input); - - result.type = CommandResult.Type.SUCCESS; - result.parsedCommand = parsedCommand; - return parsedCommand; - } else { - // No empty syntax, use default executor if any - final CommandExecutor defaultExecutor = command.getDefaultExecutor(); - if (defaultExecutor != null) { - parsedCommand.executor = defaultExecutor; - parsedCommand.context = new CommandContext(input); - - result.type = CommandResult.Type.SUCCESS; - result.parsedCommand = parsedCommand; - return parsedCommand; - } - } + private static CommandResult resultConverter(net.minestom.server.command.CommandParser.Result parseResult, String input) { + CommandResult.Type type; + if (parseResult instanceof CommandParser.Result.UnknownCommand) { + type = CommandResult.Type.UNKNOWN; + } else if (parseResult instanceof CommandParser.Result.KnownCommand.Valid) { + type = CommandResult.Type.SUCCESS; + } else if (parseResult instanceof CommandParser.Result.KnownCommand.Invalid) { + type = CommandResult.Type.INVALID_SYNTAX; + } else { + throw new IllegalStateException("Unknown CommandParser.Result type"); } - - // SYNTAXES PARSING - - // All the registered syntaxes of the command - final Collection syntaxes = command.getSyntaxes(); - // Contains all the fully validated syntaxes (we later find the one with the most amount of arguments) - List validSyntaxes = new ArrayList<>(syntaxes.size()); - // Contains all the syntaxes that are not fully correct, used to later, retrieve the "most correct syntax" - // Number of correct argument - The data about the failing argument - Int2ObjectRBTreeMap syntaxesSuggestions = new Int2ObjectRBTreeMap<>(Collections.reverseOrder()); - - for (CommandSyntax syntax : syntaxes) { - CommandParser.parse(syntax, syntax.getArguments(), args, commandString, validSyntaxes, syntaxesSuggestions); - } - - // Check if there is at least one correct syntax - if (!validSyntaxes.isEmpty()) { - CommandContext context = new CommandContext(input); - // Search the syntax with all perfect args - final ValidSyntaxHolder finalValidSyntax = CommandParser.findMostCorrectSyntax(validSyntaxes, context); - if (finalValidSyntax != null) { - // A fully correct syntax has been found, use it - final CommandSyntax syntax = finalValidSyntax.syntax(); - - parsedCommand.syntax = syntax; - parsedCommand.executor = syntax.getExecutor(); - parsedCommand.context = context; - - result.type = CommandResult.Type.SUCCESS; - result.parsedCommand = parsedCommand; - return parsedCommand; - } - } - - // No all-correct syntax, find the closest one to use the argument callback - if (!syntaxesSuggestions.isEmpty()) { - final int max = syntaxesSuggestions.firstIntKey(); // number of correct arguments in the most correct syntax - final CommandSuggestionHolder suggestionHolder = syntaxesSuggestions.get(max); - final CommandSyntax syntax = suggestionHolder.syntax(); - final ArgumentSyntaxException argumentSyntaxException = suggestionHolder.argumentSyntaxException(); - final int argIndex = suggestionHolder.argIndex(); - - // Found the closest syntax with at least 1 correct argument - final Argument argument = syntax.getArguments()[argIndex]; - if (argument.hasErrorCallback() && argumentSyntaxException != null) { - parsedCommand.callback = argument.getCallback(); - parsedCommand.argumentSyntaxException = argumentSyntaxException; - - result.type = CommandResult.Type.INVALID_SYNTAX; - result.parsedCommand = parsedCommand; - return parsedCommand; - } - } - - // No syntax found - result.type = CommandResult.Type.INVALID_SYNTAX; - result.parsedCommand = ParsedCommand.withDefaultExecutor(command, input); - return result.parsedCommand; + return CommandResult.of(type, input, ParsedCommand.fromExecutable(parseResult.executable()), null); } } diff --git a/src/main/java/net/minestom/server/command/builder/CommandResult.java b/src/main/java/net/minestom/server/command/builder/CommandResult.java index 5eeed6a72..64c988681 100644 --- a/src/main/java/net/minestom/server/command/builder/CommandResult.java +++ b/src/main/java/net/minestom/server/command/builder/CommandResult.java @@ -1,5 +1,6 @@ package net.minestom.server.command.builder; +import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -52,4 +53,14 @@ public class CommandResult { result.input = input; return result; } + + @ApiStatus.Internal + public static @NotNull CommandResult of(Type type, String input, ParsedCommand parsedCommand, CommandData data) { + CommandResult result = new CommandResult(); + result.type = type; + result.input = input; + result.parsedCommand = parsedCommand; + result.commandData = data; + return result; + } } diff --git a/src/main/java/net/minestom/server/command/builder/ParsedCommand.java b/src/main/java/net/minestom/server/command/builder/ParsedCommand.java index 46362adaa..2daa05731 100644 --- a/src/main/java/net/minestom/server/command/builder/ParsedCommand.java +++ b/src/main/java/net/minestom/server/command/builder/ParsedCommand.java @@ -1,34 +1,19 @@ package net.minestom.server.command.builder; -import net.minestom.server.MinecraftServer; import net.minestom.server.command.CommandSender; -import net.minestom.server.command.builder.condition.CommandCondition; -import net.minestom.server.command.builder.exception.ArgumentSyntaxException; +import net.minestom.server.command.ExecutableCommand; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -import java.util.List; -import java.util.Objects; - /** * Represents a {@link Command} ready to be executed (already parsed). */ public class ParsedCommand { + private final ExecutableCommand executableCommand; - // Command - protected List parents; - protected Command command; - protected String commandString; - - // Command Executor - protected CommandSyntax syntax; - - protected CommandExecutor executor; - protected CommandContext context; - - // Argument Callback - protected ArgumentCallback callback; - protected ArgumentSyntaxException argumentSyntaxException; + private ParsedCommand(ExecutableCommand executableCommand) { + this.executableCommand = executableCommand; + } /** * Executes the command for the given source. @@ -40,70 +25,11 @@ public class ParsedCommand { * @return the command data, null if none */ public @Nullable CommandData execute(@NotNull CommandSender source) { - // Global listener - command.globalListener(source, Objects.requireNonNullElseGet(context, () -> new CommandContext(commandString)), commandString); - // Command condition check - { - // Parents - if (parents != null) { - for (Command parent : parents) { - final CommandCondition condition = parent.getCondition(); - if (condition != null) { - final boolean result = condition.canUse(source, commandString); - if (!result) return null; - } - } - } - // Self - final CommandCondition condition = command.getCondition(); - if (condition != null) { - final boolean result = condition.canUse(source, commandString); - if (!result) return null; - } - } - // Condition is respected - if (executor != null) { - // An executor has been found - - if (syntax != null) { - // The executor is from a syntax - final CommandCondition commandCondition = syntax.getCommandCondition(); - if (commandCondition == null || commandCondition.canUse(source, commandString)) { - context.retrieveDefaultValues(syntax.getDefaultValuesMap()); - try { - executor.apply(source, context); - } catch (Throwable throwable) { - MinecraftServer.getExceptionManager().handleException(throwable); - } - } - } else { - // The executor is probably the default one - try { - executor.apply(source, context); - } catch (Throwable throwable) { - MinecraftServer.getExceptionManager().handleException(throwable); - } - } - } else if (callback != null && argumentSyntaxException != null) { - // No syntax has been validated but the faulty argument with a callback has been found - // Execute the faulty argument callback - callback.apply(source, argumentSyntaxException); - } - - if (context == null) { - // Argument callbacks cannot return data - return null; - } - - return context.getReturnData(); + final ExecutableCommand.Result result = executableCommand.execute(source); + return result.commandData(); } - public static @NotNull ParsedCommand withDefaultExecutor(@NotNull Command command, @NotNull String input) { - ParsedCommand parsedCommand = new ParsedCommand(); - parsedCommand.command = command; - parsedCommand.commandString = input; - parsedCommand.executor = command.getDefaultExecutor(); - parsedCommand.context = new CommandContext(input); - return parsedCommand; + public static @NotNull ParsedCommand fromExecutable(ExecutableCommand executableCommand) { + return new ParsedCommand(executableCommand); } } diff --git a/src/main/java/net/minestom/server/command/builder/exception/ArgumentSyntaxException.java b/src/main/java/net/minestom/server/command/builder/exception/ArgumentSyntaxException.java index 72281ff93..13552c96c 100644 --- a/src/main/java/net/minestom/server/command/builder/exception/ArgumentSyntaxException.java +++ b/src/main/java/net/minestom/server/command/builder/exception/ArgumentSyntaxException.java @@ -24,6 +24,12 @@ public class ArgumentSyntaxException extends RuntimeException { this.errorCode = errorCode; } + @Override + public Throwable fillInStackTrace() { + // Stacktrace is useless to the parser + return this; + } + /** * Gets the problematic command input. * diff --git a/src/main/java/net/minestom/server/command/builder/parser/ArgumentParser.java b/src/main/java/net/minestom/server/command/builder/parser/ArgumentParser.java index 8e4255ec0..0ff0af66f 100644 --- a/src/main/java/net/minestom/server/command/builder/parser/ArgumentParser.java +++ b/src/main/java/net/minestom/server/command/builder/parser/ArgumentParser.java @@ -2,7 +2,10 @@ package net.minestom.server.command.builder.parser; import net.minestom.server.command.builder.arguments.*; import net.minestom.server.command.builder.arguments.minecraft.*; -import net.minestom.server.command.builder.arguments.minecraft.registry.*; +import net.minestom.server.command.builder.arguments.minecraft.registry.ArgumentEnchantment; +import net.minestom.server.command.builder.arguments.minecraft.registry.ArgumentEntityType; +import net.minestom.server.command.builder.arguments.minecraft.registry.ArgumentParticle; +import net.minestom.server.command.builder.arguments.minecraft.registry.ArgumentPotionEffect; import net.minestom.server.command.builder.arguments.number.ArgumentDouble; import net.minestom.server.command.builder.arguments.number.ArgumentFloat; import net.minestom.server.command.builder.arguments.number.ArgumentInteger; diff --git a/src/main/java/net/minestom/server/listener/TabCompleteListener.java b/src/main/java/net/minestom/server/listener/TabCompleteListener.java index c705a7299..9d2074de5 100644 --- a/src/main/java/net/minestom/server/listener/TabCompleteListener.java +++ b/src/main/java/net/minestom/server/listener/TabCompleteListener.java @@ -1,24 +1,13 @@ package net.minestom.server.listener; import net.minestom.server.MinecraftServer; -import net.minestom.server.command.CommandManager; import net.minestom.server.command.CommandSender; -import net.minestom.server.command.builder.CommandSyntax; -import net.minestom.server.command.builder.arguments.Argument; -import net.minestom.server.command.builder.parser.ArgumentQueryResult; -import net.minestom.server.command.builder.parser.CommandParser; -import net.minestom.server.command.builder.parser.CommandQueryResult; import net.minestom.server.command.builder.suggestion.Suggestion; -import net.minestom.server.command.builder.suggestion.SuggestionCallback; import net.minestom.server.entity.Player; import net.minestom.server.network.packet.client.play.ClientTabCompletePacket; import net.minestom.server.network.packet.server.play.TabCompletePacket; -import net.minestom.server.utils.StringUtils; import org.jetbrains.annotations.Nullable; -import java.util.Arrays; -import java.util.regex.Pattern; - public class TabCompleteListener { public static void listener(ClientTabCompletePacket packet, Player player) { @@ -37,43 +26,15 @@ public class TabCompleteListener { } public static @Nullable Suggestion getSuggestion(CommandSender commandSender, String text) { - String commandString = text.replaceFirst(CommandManager.COMMAND_PREFIX, ""); - String[] split = commandString.split(StringUtils.SPACE); - String commandName = split[0]; - String args = commandString.replaceFirst(Pattern.quote(commandName), ""); - - final CommandQueryResult commandQueryResult = CommandParser.findCommand(MinecraftServer.getCommandManager().getDispatcher(), commandString); - if (commandQueryResult == null) { - // Command not found - return null; + if (text.startsWith("/")) { + text = text.substring(1); } - - final ArgumentQueryResult queryResult = CommandParser.findEligibleArgument(commandQueryResult.command(), - commandQueryResult.args(), commandString, text.endsWith(StringUtils.SPACE), false, - CommandSyntax::hasSuggestion, Argument::hasSuggestion); - if (queryResult == null) { - // Suggestible argument not found - return null; + if (text.endsWith(" ")) { + // Append a placeholder char if the command ends with a space allowing the parser to find suggestion + // for the next arg without typing the first char of it, this is probably the most hacky solution, but hey + // it works as intended :) + text = text + '\00'; } - - final Argument argument = queryResult.argument(); - - final SuggestionCallback suggestionCallback = argument.getSuggestionCallback(); - if (suggestionCallback != null) { - final String input = queryResult.input(); - final int inputLength = input.length(); - - final int commandLength = Arrays.stream(split).map(String::length).reduce(0, Integer::sum) + - StringUtils.countMatches(args, StringUtils.SPACE_CHAR); - final int trailingSpaces = !input.isEmpty() ? text.length() - text.trim().length() : 0; - - final int start = commandLength - inputLength + 1 - trailingSpaces; - - Suggestion suggestion = new Suggestion(input, start, inputLength); - suggestionCallback.apply(commandSender, queryResult.context(), suggestion); - - return suggestion; - } - return null; + return MinecraftServer.getCommandManager().parseCommand(text).suggestion(commandSender); } } diff --git a/src/test/java/net/minestom/server/command/CommandPacketTest.java b/src/test/java/net/minestom/server/command/CommandPacketTest.java index f0aecb809..512aadd11 100644 --- a/src/test/java/net/minestom/server/command/CommandPacketTest.java +++ b/src/test/java/net/minestom/server/command/CommandPacketTest.java @@ -191,6 +191,42 @@ public class CommandPacketTest { """, graph); } + @Test + public void cmdArgShortcut() { + var foo = Graph.builder(ArgumentType.Literal("foo")) + .append(ArgumentType.String("msg")) + .build(); + var bar = Graph.builder(ArgumentType.Literal("bar")) + .append(ArgumentType.Command("cmd").setShortcut("foo")) + .build(); + assertPacketGraph(""" + foo bar cmd=% + 0->foo bar + bar->cmd + cmd+>foo + foo->msg + msg=! brigadier:string 1 + """, foo, bar); + } + + @Test + public void cmdArgShortcutWithPartialArg() { + var foo = Graph.builder(ArgumentType.Literal("foo")) + .append(ArgumentType.String("msg")) + .build(); + var bar = Graph.builder(ArgumentType.Literal("bar")) + .append(ArgumentType.Command("cmd").setShortcut("foo \"prefix ")) + .build(); + assertPacketGraph(""" + foo bar cmd=% + 0->foo bar + bar->cmd + cmd+>foo + foo->msg + msg=! brigadier:string 1 + """, foo, bar); + } + static void assertPacketGraph(String expected, Graph... graphs) { var packet = GraphConverter.createPacket(Graph.merge(graphs), null); CommandTestUtils.assertPacket(packet, expected); diff --git a/src/test/java/net/minestom/server/command/CommandParseTest.java b/src/test/java/net/minestom/server/command/CommandParseTest.java new file mode 100644 index 000000000..fb689bcae --- /dev/null +++ b/src/test/java/net/minestom/server/command/CommandParseTest.java @@ -0,0 +1,147 @@ +package net.minestom.server.command; + +import net.minestom.server.command.builder.arguments.ArgumentType; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.Test; + +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +import static net.minestom.server.command.builder.arguments.ArgumentType.Literal; +import static net.minestom.server.command.builder.arguments.ArgumentType.Word; +import static org.junit.jupiter.api.Assertions.*; + +public class CommandParseTest { + + @Test + public void singleParameterlessCommand() { + final AtomicBoolean b = new AtomicBoolean(); + var foo = Graph.merge(Graph.builder(Literal("foo"), createExecutor(b)).build()); + assertValid(foo, "foo", b); + assertUnknown(foo, "bar"); + assertSyntaxError(foo, "foo bar baz"); + } + + @Test + public void twoParameterlessCommand() { + final AtomicBoolean b = new AtomicBoolean(); + final AtomicBoolean b1 = new AtomicBoolean(); + var graph = Graph.merge( + Graph.builder(Literal("foo"), createExecutor(b)).build(), + Graph.builder(Literal("bar"), createExecutor(b1)).build() + ); + assertValid(graph, "foo", b); + assertValid(graph, "bar", b1); + assertUnknown(graph, "baz"); + assertSyntaxError(graph, "foo bar baz"); + assertSyntaxError(graph, "bar 25"); + } + + @Test + public void singleCommandWithMultipleSyntax() { + final AtomicBoolean add = new AtomicBoolean(); + final AtomicBoolean action = new AtomicBoolean(); + var foo = Graph.merge(Graph.builder(Literal("foo")) + .append(Literal("add"), + x -> x.append(Word("name"), createExecutor(add))) + .append(Word("action").from("inc", "dec"), + x -> x.append(ArgumentType.Integer("num"), createExecutor(action))) + .build()); + assertValid(foo, "foo add test", add); + assertValid(foo, "foo add inc", add); + assertValid(foo, "foo add 157", add); + assertValid(foo, "foo inc 157", action); + assertValid(foo, "foo dec 157", action); + assertSyntaxError(foo, "foo 15"); + assertSyntaxError(foo, "foo asd"); + assertSyntaxError(foo, "foo inc"); + assertSyntaxError(foo, "foo inc asd"); + assertSyntaxError(foo, "foo inc 15 dec"); + assertSyntaxError(foo, "foo inc 15 20"); + assertUnknown(foo, "bar"); + assertUnknown(foo, "add"); + } + + @Test + public void singleCommandOptionalArgs() { + final AtomicBoolean b = new AtomicBoolean(); + final AtomicReference expectedFirstArg = new AtomicReference<>("T"); + var foo = Graph.merge(Graph.builder(Literal("foo")) + .append(Word("a").setDefaultValue("A"), + x -> x.append(Word("b").setDefaultValue("B"), + x1 -> x1.append(Word("c").setDefaultValue("C"), + x2 -> x2.append(Word("d").setDefaultValue("D"), + new GraphImpl.ExecutionImpl(null, null, null, + (sender, context) -> { + b.set(true); + assertEquals(expectedFirstArg.get(), context.get("a")); + assertEquals("B", context.get("b")); + assertEquals("C", context.get("c")); + assertEquals("D", context.get("d")); + }, null))))) + .build()); + assertValid(foo, "foo T", b); + expectedFirstArg.set("A"); + assertValid(foo, "foo", b); + } + + @Test + public void singleCommandSingleEnumArg() { + enum A {a, b} + final AtomicBoolean b = new AtomicBoolean(); + var foo = Graph.merge(Graph.builder(Literal("foo")) + .append(ArgumentType.Enum("test", A.class), createExecutor(b)) + .build()); + assertValid(foo, "foo a", b); + assertValid(foo, "foo b", b); + assertSyntaxError(foo, "foo c"); + assertSyntaxError(foo, "foo"); + } + + @Test + public void aliasWithoutArgs() { + final AtomicBoolean b = new AtomicBoolean(); + var foo = Graph.merge(Graph.builder(Word("").from("foo", "bar"), createExecutor(b)) + .build()); + assertValid(foo, "foo", b); + assertValid(foo, "bar", b); + assertUnknown(foo, "test"); + } + + @Test + public void aliasWithArgs() { + final AtomicBoolean b = new AtomicBoolean(); + var foo = Graph.merge(Graph.builder(Word("").from("foo", "bar")) + .append(ArgumentType.Integer("test"), createExecutor(b)) + .build()); + assertValid(foo, "foo 1", b); + assertValid(foo, "bar 1", b); + assertSyntaxError(foo, "foo"); + assertSyntaxError(foo, "bar"); + } + + private static void assertSyntaxError(Graph graph, String input) { + assertInstanceOf(CommandParser.Result.KnownCommand.Invalid.class, parseCommand(graph, input)); + } + + private static void assertUnknown(Graph graph, String input) { + assertInstanceOf(CommandParser.Result.UnknownCommand.class, parseCommand(graph, input)); + } + + private static void assertValid(Graph graph, String input, AtomicBoolean executorTest) { + final CommandParser.Result result = parseCommand(graph, input); + assertInstanceOf(CommandParser.Result.KnownCommand.Valid.class, result); + result.executable().execute(null); + assertTrue(executorTest.get(), "Parser returned valid syntax, but with the wrong executor."); + executorTest.set(false); + } + + private static CommandParser.Result parseCommand(Graph graph, String input) { + return CommandParser.parser().parse(graph, input); + } + + @NotNull + private static Graph.Execution createExecutor(AtomicBoolean atomicBoolean) { + return new GraphImpl.ExecutionImpl(null, null, null, (sender, context) -> atomicBoolean.set(true), null); + } +} diff --git a/src/test/java/net/minestom/server/command/CommandSuggestionIntegrationTest.java b/src/test/java/net/minestom/server/command/CommandSuggestionIntegrationTest.java new file mode 100644 index 000000000..8bf4e0d7b --- /dev/null +++ b/src/test/java/net/minestom/server/command/CommandSuggestionIntegrationTest.java @@ -0,0 +1,51 @@ +package net.minestom.server.command; + +import net.minestom.server.api.Env; +import net.minestom.server.api.EnvTest; +import net.minestom.server.command.builder.Command; +import net.minestom.server.command.builder.suggestion.SuggestionEntry; +import net.minestom.server.coordinate.Pos; +import net.minestom.server.network.packet.client.play.ClientTabCompletePacket; +import net.minestom.server.network.packet.server.play.TabCompletePacket; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static net.minestom.server.command.builder.arguments.ArgumentType.Literal; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +@EnvTest +public class CommandSuggestionIntegrationTest { + + @Test + public void suggestion(Env env) { + var instance = env.createFlatInstance(); + var connection = env.createConnection(); + var player = connection.connect(instance, new Pos(0, 42, 0)).join(); + + var command = new Command("test"); + command.addSyntax((sender, context) -> { + + }, Literal("arg").setSuggestionCallback((sender, context, suggestion) -> { + assertEquals(player, sender); + assertNull(context.get("arg")); + assertEquals("test", context.getCommandName()); + assertEquals("test te", context.getInput()); + suggestion.addEntry(new SuggestionEntry("test1")); + })); + + env.process().command().register(command); + + var listener = connection.trackIncoming(TabCompletePacket.class); + player.addPacketToQueue(new ClientTabCompletePacket(3, "test te")); + player.interpretPacketQueue(); + + listener.assertSingle(tabCompletePacket -> { + assertEquals(3, tabCompletePacket.transactionId()); + assertEquals(6, tabCompletePacket.start()); + assertEquals(2, tabCompletePacket.length()); + assertEquals(List.of(new TabCompletePacket.Match("test1", null)), tabCompletePacket.matches()); + }); + } +} diff --git a/src/test/java/net/minestom/server/command/CommandTestUtils.java b/src/test/java/net/minestom/server/command/CommandTestUtils.java index 6a46fd8fa..ea0d9fc24 100644 --- a/src/test/java/net/minestom/server/command/CommandTestUtils.java +++ b/src/test/java/net/minestom/server/command/CommandTestUtils.java @@ -10,7 +10,8 @@ import java.util.concurrent.atomic.AtomicReference; import java.util.function.Function; import java.util.stream.Collectors; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.fail; public class CommandTestUtils { @@ -19,9 +20,20 @@ public class CommandTestUtils { final List actualList = NodeStructure.fromString(NodeStructure.packetToString(packet)); try { assertEquals(expectedList.size(), actualList.size(), "Different node counts"); - assertTrue(actualList.containsAll(expectedList), "Packet doesn't contain all expected nodes."); + for (NodeStructure.TestNode expected : expectedList) { + boolean found = false; + for (NodeStructure.TestNode actual : actualList) { + if (expected.equals(actual)) { + found = true; + break; + } + } + if (!found) { + fail("Packet doesn't contain " + expected.toString()); + } + } } catch (AssertionFailedError error) { - fail("Graphs didn't match. Actual graph from packet: " + CommandTestUtils.exportGarphvizDot(packet, false)); + fail("Graphs didn't match. Actual graph from packet: " + CommandTestUtils.exportGarphvizDot(packet, false), error); } } diff --git a/src/test/java/net/minestom/server/command/GraphConversionExecutorTest.java b/src/test/java/net/minestom/server/command/GraphConversionExecutorTest.java index 06d4f3c6e..55e5612f3 100644 --- a/src/test/java/net/minestom/server/command/GraphConversionExecutorTest.java +++ b/src/test/java/net/minestom/server/command/GraphConversionExecutorTest.java @@ -2,19 +2,13 @@ package net.minestom.server.command; import net.minestom.server.command.builder.Command; import net.minestom.server.command.builder.CommandContext; +import net.minestom.server.command.builder.condition.CommandCondition; import org.junit.jupiter.api.Test; import static net.minestom.server.command.builder.arguments.ArgumentType.Literal; import static org.junit.jupiter.api.Assertions.*; public class GraphConversionExecutorTest { - @Test - public void empty() { - final Command foo = new Command("foo"); - var graph = Graph.fromCommand(foo); - assertNull(graph.root().execution()); - } - @Test public void defaultCondition() { final Command foo = new Command("foo"); @@ -43,7 +37,10 @@ public class GraphConversionExecutorTest { var graph = Graph.fromCommand(foo); assertEquals(1, graph.root().next().size()); - assertNull(graph.root().next().get(0).execution()); + var execution = graph.root().next().get(0).execution(); + assertNotNull(execution); + assertNull(execution.condition()); + assertNotNull(execution.executor()); } @Test @@ -72,6 +69,18 @@ public class GraphConversionExecutorTest { assertFalse(execution.test(null)); } + @Test + public void commandConditionFalse() { + final Command foo = new Command("foo"); + foo.setCondition((sender, commandString) -> false); + final Graph graph = Graph.fromCommand(foo); + final Graph.Execution execution = graph.root().execution(); + assertNotNull(execution); + final CommandCondition condition = execution.condition(); + assertNotNull(condition); + assertFalse(condition.canUse(null, null)); + } + private static void dummyExecutor(CommandSender sender, CommandContext context) { } }