New command parser (#1235)

This commit is contained in:
Noel Németh 2022-07-25 19:34:40 +02:00 committed by GitHub
parent 58a7a59036
commit 9e5de35fa7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 1128 additions and 368 deletions

View File

@ -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<String, Object> 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"));
}
}

View File

@ -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}.
* <p>
@ -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<String, Command> commandMap = new HashMap<>();
private final Set<Command> 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());
}
}

View File

@ -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<Argument<?>> 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 {
}
}
}
}

View File

@ -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<NodeResult> nodeResults = new ArrayDeque<>();
final List<CommandCondition> conditions = new ArrayList<>();
final List<CommandExecutor> 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<String, ArgumentResult<Object>> collectArguments() {
return nodeResults.stream()
.skip(1) // skip root
.collect(Collectors.toUnmodifiableMap(NodeResult::name, NodeResult::argumentResult));
}
List<Argument<?>> 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<Object> 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 <R, T> @Nullable R nullSafeGetter(@Nullable T obj, Function<T, R> 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<Object>) success,
argument.getSuggestionCallback());
} else if (parse instanceof ArgumentResult.SyntaxError<?> syntaxError) {
return new NodeResult(child, (ArgumentResult<Object>) 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<Argument<?>> args() {
return null;
}
}
sealed interface InternalKnownCommand extends Result.KnownCommand {
String input();
@Nullable CommandCondition condition();
@NotNull Map<String, ArgumentResult<Object>> 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<String, ArgumentResult<Object>> arguments, CommandExecutor globalListener,
@Nullable SuggestionCallback suggestionCallback, List<Argument<?>> 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<String, ArgumentResult<Object>> arguments,
CommandExecutor globalListener, @Nullable SuggestionCallback suggestionCallback, List<Argument<?>> 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<String, ArgumentResult<Object>> 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<String, ArgumentResult<Object>> 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<String, ArgumentResult<Object>> arguments) {
final CommandContext context = new CommandContext(input);
for (var entry : arguments.entrySet()) {
final String identifier = entry.getKey();
final ArgumentResult<Object> value = entry.getValue();
final Object argOutput = value instanceof ArgumentResult.Success<Object> success ? success.value() : null;
final String argInput = value instanceof ArgumentResult.Success<Object> 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<Object> 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 <T> ArgumentResult<T> parse(Argument<T> 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<R> {
record Success<T>(T value, String input)
implements ArgumentResult<T> {
}
record IncompatibleType<T>()
implements ArgumentResult<T> {
}
record SyntaxError<T>(String message, String input, int code)
implements ArgumentResult<T> {
}
}
}

View File

@ -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
}
}
}

View File

@ -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<CommandSender> 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<Builder> consumer);
@NotNull Builder append(@NotNull Argument<?> argument, @Nullable Execution execution, @NotNull Consumer<Builder> 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<Builder> consumer) {
return append(argument, null, consumer);
}
default @NotNull Builder append(@NotNull Argument<?> argument) {
return append(argument, (Execution) null);
}
@NotNull Graph build();
}

View File

@ -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<DeclareCommandsPacket.Node> nodes = new ArrayList<>();
List<Consumer<Integer>> rootRedirect = new ArrayList<>();
List<BiConsumer<Graph, Integer>> redirects = new ArrayList<>();
Map<Argument<?>, 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<DeclareCommandsPacket.Node> to,
List<Consumer<Integer>> rootRedirect, AtomicInteger id, @Nullable AtomicInteger redirect,
List<Runnable> redirectSetters, @Nullable Player player) {
List<BiConsumer<Graph, Integer>> redirects, AtomicInteger id, @Nullable AtomicInteger redirect,
@Nullable Player player, Map<Argument<?>, 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<Argument<?>> 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<String> entries = argument instanceof ArgumentEnum<?> ? ((ArgumentEnum<?>) argument).entries() : Arrays.stream(((ArgumentWord) argument).getRestrictions()).toList();
List<String> 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<Runnable> 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()};

View File

@ -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<BuilderImpl> children) implements Graph.Builder {
public BuilderImpl(Argument<?> argument) {
this(argument, new ArrayList<>());
record BuilderImpl(Argument<?> argument, List<BuilderImpl> 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<Graph.Builder> consumer) {
BuilderImpl builder = new BuilderImpl(argument);
public Graph.@NotNull Builder append(@NotNull Argument<?> argument, @Nullable Execution execution,
@NotNull Consumer<Graph.Builder> 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<BuilderImpl> 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<CommandSender> predicate) implements Execution {
record ExecutionImpl(Predicate<CommandSender> 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);
}
}

View File

@ -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<String, Command> commandMap = new HashMap<>();
private final Set<Command> commands = new HashSet<>();
private final CommandManager manager;
private final Cache<String, CommandResult> 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<Command> 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<CommandSyntax> 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<CommandSyntax> syntaxes = command.getSyntaxes();
// Contains all the fully validated syntaxes (we later find the one with the most amount of arguments)
List<ValidSyntaxHolder> 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<CommandSuggestionHolder> 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);
}
}

View File

@ -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;
}
}

View File

@ -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<Command> 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);
}
}

View File

@ -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.
*

View File

@ -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;

View File

@ -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);
}
}

View File

@ -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);

View File

@ -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<String> 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);
}
}

View File

@ -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());
});
}
}

View File

@ -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<NodeStructure.TestNode> 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);
}
}

View File

@ -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) {
}
}