From 703b91e0fa9a4b52fcc329d64a94c1707648139b Mon Sep 17 00:00:00 2001 From: Hugo Manrique Date: Sat, 5 Jun 2021 23:19:01 +0200 Subject: [PATCH] Command implementation refactor --- .../proxy/command/CommandGraphInjector.java | 123 ++++++++ .../command/CommandInvocationFactory.java | 40 --- .../proxy/command/CommandNodeFactory.java | 94 ------ .../proxy/command/SuggestionsProvider.java | 271 ++++++++++++++++-- .../proxy/command/VelocityCommandManager.java | 121 +++++--- .../proxy/command/VelocityCommandMeta.java | 83 +++++- .../proxy/command/VelocityCommands.java | 174 +++++++++++ .../command/VelocityRawCommandInvocation.java | 54 ---- .../VelocitySimpleCommandInvocation.java | 52 ---- .../brigadier/StringArrayArgumentType.java | 46 +++ .../brigadier/VelocityArgumentBuilder.java | 61 ++++ .../VelocityArgumentCommandNode.java | 121 ++++++++ .../AbstractCommandInvocation.java | 30 +- .../invocation/CommandInvocationFactory.java | 78 +++++ .../invocation/RawCommandInvocation.java | 90 ++++++ .../invocation/SimpleCommandInvocation.java | 93 ++++++ .../registrar/AbstractCommandRegistrar.java | 44 +++ .../registrar/BrigadierCommandRegistrar.java | 46 +++ .../command/registrar/CommandRegistrar.java | 33 +++ .../registrar/InvocableCommandRegistrar.java | 105 +++++++ .../registrar/RawCommandRegistrar.java | 24 ++ .../registrar/SimpleCommandRegistrar.java | 24 ++ .../backend/BackendPlaySessionHandler.java | 59 +--- .../proxy/util/BrigadierUtils.java | 169 ----------- .../command/CommandGraphInjectorTests.java | 53 ++++ .../command/SuggestionsProviderTests.java | 5 + .../StringArrayArgumentTypeTests.java | 94 ++++++ .../VelocityArgumentCommandNodeTests.java | 81 ++++++ 28 files changed, 1726 insertions(+), 542 deletions(-) create mode 100644 proxy/src/main/java/com/velocitypowered/proxy/command/CommandGraphInjector.java delete mode 100644 proxy/src/main/java/com/velocitypowered/proxy/command/CommandInvocationFactory.java delete mode 100644 proxy/src/main/java/com/velocitypowered/proxy/command/CommandNodeFactory.java create mode 100644 proxy/src/main/java/com/velocitypowered/proxy/command/VelocityCommands.java delete mode 100644 proxy/src/main/java/com/velocitypowered/proxy/command/VelocityRawCommandInvocation.java delete mode 100644 proxy/src/main/java/com/velocitypowered/proxy/command/VelocitySimpleCommandInvocation.java create mode 100644 proxy/src/main/java/com/velocitypowered/proxy/command/brigadier/StringArrayArgumentType.java create mode 100644 proxy/src/main/java/com/velocitypowered/proxy/command/brigadier/VelocityArgumentBuilder.java create mode 100644 proxy/src/main/java/com/velocitypowered/proxy/command/brigadier/VelocityArgumentCommandNode.java rename proxy/src/main/java/com/velocitypowered/proxy/command/{ => invocation}/AbstractCommandInvocation.java (69%) create mode 100644 proxy/src/main/java/com/velocitypowered/proxy/command/invocation/CommandInvocationFactory.java create mode 100644 proxy/src/main/java/com/velocitypowered/proxy/command/invocation/RawCommandInvocation.java create mode 100644 proxy/src/main/java/com/velocitypowered/proxy/command/invocation/SimpleCommandInvocation.java create mode 100644 proxy/src/main/java/com/velocitypowered/proxy/command/registrar/AbstractCommandRegistrar.java create mode 100644 proxy/src/main/java/com/velocitypowered/proxy/command/registrar/BrigadierCommandRegistrar.java create mode 100644 proxy/src/main/java/com/velocitypowered/proxy/command/registrar/CommandRegistrar.java create mode 100644 proxy/src/main/java/com/velocitypowered/proxy/command/registrar/InvocableCommandRegistrar.java create mode 100644 proxy/src/main/java/com/velocitypowered/proxy/command/registrar/RawCommandRegistrar.java create mode 100644 proxy/src/main/java/com/velocitypowered/proxy/command/registrar/SimpleCommandRegistrar.java delete mode 100644 proxy/src/main/java/com/velocitypowered/proxy/util/BrigadierUtils.java create mode 100644 proxy/src/test/java/com/velocitypowered/proxy/command/CommandGraphInjectorTests.java create mode 100644 proxy/src/test/java/com/velocitypowered/proxy/command/SuggestionsProviderTests.java create mode 100644 proxy/src/test/java/com/velocitypowered/proxy/command/brigadier/StringArrayArgumentTypeTests.java create mode 100644 proxy/src/test/java/com/velocitypowered/proxy/command/brigadier/VelocityArgumentCommandNodeTests.java diff --git a/proxy/src/main/java/com/velocitypowered/proxy/command/CommandGraphInjector.java b/proxy/src/main/java/com/velocitypowered/proxy/command/CommandGraphInjector.java new file mode 100644 index 000000000..8ea2d5775 --- /dev/null +++ b/proxy/src/main/java/com/velocitypowered/proxy/command/CommandGraphInjector.java @@ -0,0 +1,123 @@ +package com.velocitypowered.proxy.command; + +import com.google.common.base.Preconditions; +import com.mojang.brigadier.CommandDispatcher; +import com.mojang.brigadier.StringReader; +import com.mojang.brigadier.builder.ArgumentBuilder; +import com.mojang.brigadier.context.CommandContextBuilder; +import com.mojang.brigadier.context.StringRange; +import com.mojang.brigadier.tree.CommandNode; +import com.mojang.brigadier.tree.LiteralCommandNode; +import com.mojang.brigadier.tree.RootCommandNode; +import com.velocitypowered.proxy.command.brigadier.VelocityArgumentCommandNode; +import java.util.concurrent.locks.Lock; +import org.checkerframework.checker.lock.qual.GuardedBy; +import org.checkerframework.checker.nullness.qual.Nullable; + +/** + * Copies the nodes of a {@link RootCommandNode} to a possibly non-empty + * destination {@link RootCommandNode}, respecting the requirements satisfied + * by a given command source. + * + * @param the type of the source to inject the nodes for + */ +public final class CommandGraphInjector { + + private static final StringRange ALIAS_RANGE = StringRange.at(0); + private static final StringReader ALIAS_READER = new StringReader(""); + + private final @GuardedBy("lock") CommandDispatcher dispatcher; + private final Lock lock; + + CommandGraphInjector(final CommandDispatcher dispatcher, final Lock lock) { + this.dispatcher = Preconditions.checkNotNull(dispatcher, "dispatcher"); + this.lock = Preconditions.checkNotNull(lock, "lock"); + } + + // The term "source" is ambiguous here. We use "origin" when referring to + // the root node we are copying nodes from to the destination node. + + /** + * Adds the node from the root node of this injector to the given root node, + * respecting the requirements satisfied by the given source. + * + *

Prior to adding a literal with the same name as one previously contained + * in the destination node, the old node is removed from the destination node. + * + * @param dest the root node to add the permissible nodes to + * @param source the command source to inject the nodes for + */ + public void inject(final RootCommandNode dest, final S source) { + lock.lock(); + try { + final RootCommandNode origin = this.dispatcher.getRoot(); + final CommandContextBuilder rootContext = + new CommandContextBuilder<>(this.dispatcher, source, origin, 0); + + // Filter alias nodes + for (final CommandNode node : origin.getChildren()) { + if (!node.canUse(source)) { + continue; + } + + final CommandContextBuilder context = rootContext.copy() + .withNode(node, ALIAS_RANGE); + if (!node.canUse(context, ALIAS_READER)) { + continue; + } + + final LiteralCommandNode asLiteral = (LiteralCommandNode) node; + final LiteralCommandNode copy = asLiteral.createBuilder().build(); + final VelocityArgumentCommandNode argsNode = + VelocityCommands.getArgumentsNode(asLiteral); + if (argsNode == null) { + // This literal is associated to a BrigadierCommand, filter normally + this.copyChildren(node, copy, source); + } else { + // Copy all children nodes (arguments node and hints) + for (final CommandNode child : node.getChildren()) { + copy.addChild(child); + } + } + this.addAlias(copy, dest); + } + } finally { + lock.unlock(); + } + } + + private @Nullable CommandNode filterNode(final CommandNode node, final S source) { + // We only check the non-context requirement when filtering alias nodes. + // Otherwise, we would need to manually craft context builder and reader instances, + // which is both incorrect and inefficient. The reason why we can do so for alias + // literals is due to the empty string being a valid and expected input by + // the context-aware requirement (when suggesting the literal name). + if (!node.canUse(source)) { + return null; + } + final ArgumentBuilder builder = node.createBuilder(); + if (node.getRedirect() != null) { + // TODO Document redirects to non-Brigadier commands are not supported + final CommandNode target = this.filterNode(node.getRedirect(), source); + builder.forward(target, builder.getRedirectModifier(), builder.isFork()); + } + final CommandNode result = builder.build(); + this.copyChildren(node, result, source); + return result; + } + + private void copyChildren(final CommandNode parent, final CommandNode dest, + final S source) { + for (final CommandNode child : parent.getChildren()) { + final CommandNode filtered = this.filterNode(child, source); + if (filtered != null) { + dest.addChild(filtered); + } + } + } + + private void addAlias(final LiteralCommandNode node, final RootCommandNode dest) { + dest.removeChildByName(node.getName()); + dest.addChild(node); + } +} diff --git a/proxy/src/main/java/com/velocitypowered/proxy/command/CommandInvocationFactory.java b/proxy/src/main/java/com/velocitypowered/proxy/command/CommandInvocationFactory.java deleted file mode 100644 index 05a4d3b90..000000000 --- a/proxy/src/main/java/com/velocitypowered/proxy/command/CommandInvocationFactory.java +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Copyright (C) 2018 Velocity Contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package com.velocitypowered.proxy.command; - -import com.mojang.brigadier.context.CommandContext; -import com.velocitypowered.api.command.CommandInvocation; -import com.velocitypowered.api.command.CommandSource; - -/** - * Creates command invocation contexts for the given {@link CommandSource} - * and command line arguments. - * - * @param the type of the built invocation - */ -@FunctionalInterface -public interface CommandInvocationFactory> { - - /** - * Returns an invocation context for the given Brigadier context. - * - * @param context the command context - * @return the built invocation context - */ - I create(final CommandContext context); -} diff --git a/proxy/src/main/java/com/velocitypowered/proxy/command/CommandNodeFactory.java b/proxy/src/main/java/com/velocitypowered/proxy/command/CommandNodeFactory.java deleted file mode 100644 index 5f68066f8..000000000 --- a/proxy/src/main/java/com/velocitypowered/proxy/command/CommandNodeFactory.java +++ /dev/null @@ -1,94 +0,0 @@ -/* - * Copyright (C) 2018 Velocity Contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package com.velocitypowered.proxy.command; - -import com.mojang.brigadier.context.CommandContext; -import com.mojang.brigadier.tree.LiteralCommandNode; -import com.velocitypowered.api.command.BrigadierCommand; -import com.velocitypowered.api.command.Command; -import com.velocitypowered.api.command.CommandInvocation; -import com.velocitypowered.api.command.CommandSource; -import com.velocitypowered.api.command.InvocableCommand; -import com.velocitypowered.api.command.RawCommand; -import com.velocitypowered.api.command.SimpleCommand; -import com.velocitypowered.proxy.util.BrigadierUtils; - -@FunctionalInterface -public interface CommandNodeFactory { - - InvocableCommandNodeFactory SIMPLE = - new InvocableCommandNodeFactory() { - @Override - protected SimpleCommand.Invocation createInvocation( - final CommandContext context) { - return VelocitySimpleCommandInvocation.FACTORY.create(context); - } - }; - - InvocableCommandNodeFactory RAW = - new InvocableCommandNodeFactory() { - @Override - protected RawCommand.Invocation createInvocation( - final CommandContext context) { - return VelocityRawCommandInvocation.FACTORY.create(context); - } - }; - - /** - * Returns a Brigadier node for the execution of the given command. - * - * @param alias the command alias - * @param command the command to execute - * @return the command node - */ - LiteralCommandNode create(String alias, T command); - - abstract class InvocableCommandNodeFactory> - implements CommandNodeFactory> { - - @Override - public LiteralCommandNode create( - final String alias, final InvocableCommand command) { - return BrigadierUtils.buildRawArgumentsLiteral(alias, - context -> { - I invocation = createInvocation(context); - if (!command.hasPermission(invocation)) { - return BrigadierCommand.FORWARD; - } - command.execute(invocation); - return 1; - }, - (context, builder) -> { - I invocation = createInvocation(context); - - if (!command.hasPermission(invocation)) { - return builder.buildFuture(); - } - return command.suggestAsync(invocation).thenApply(values -> { - for (String value : values) { - builder.suggest(value); - } - - return builder.build(); - }); - }); - } - - protected abstract I createInvocation(final CommandContext context); - } -} diff --git a/proxy/src/main/java/com/velocitypowered/proxy/command/SuggestionsProvider.java b/proxy/src/main/java/com/velocitypowered/proxy/command/SuggestionsProvider.java index 7a1ea903b..869d7ccb7 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/command/SuggestionsProvider.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/command/SuggestionsProvider.java @@ -21,14 +21,26 @@ import com.google.common.base.Preconditions; import com.mojang.brigadier.CommandDispatcher; import com.mojang.brigadier.ParseResults; import com.mojang.brigadier.StringReader; +import com.mojang.brigadier.context.CommandContext; import com.mojang.brigadier.context.CommandContextBuilder; import com.mojang.brigadier.context.StringRange; +import com.mojang.brigadier.exceptions.CommandSyntaxException; import com.mojang.brigadier.suggestion.Suggestions; +import com.mojang.brigadier.suggestion.SuggestionsBuilder; import com.mojang.brigadier.tree.CommandNode; import com.mojang.brigadier.tree.LiteralCommandNode; -import com.mojang.brigadier.tree.RootCommandNode; +import com.velocitypowered.api.command.Command; +import com.velocitypowered.proxy.command.brigadier.VelocityArgumentCommandNode; +import java.util.ArrayList; import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Locale; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.locks.Lock; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.checkerframework.checker.lock.qual.GuardedBy; /** * Provides suggestions for a given command input. @@ -41,10 +53,16 @@ import java.util.concurrent.CompletableFuture; */ final class SuggestionsProvider { - private final CommandDispatcher dispatcher; + private static final Logger LOGGER = LogManager.getLogger(SuggestionsProvider.class); - SuggestionsProvider(final CommandDispatcher dispatcher) { + private static final StringRange ALIAS_SUGGESTION_RANGE = StringRange.at(0); + + private final @GuardedBy("lock") CommandDispatcher dispatcher; + private final Lock lock; + + SuggestionsProvider(final CommandDispatcher dispatcher, final Lock lock) { this.dispatcher = Preconditions.checkNotNull(dispatcher, "dispatcher"); + this.lock = Preconditions.checkNotNull(lock, "lock"); } /** @@ -69,22 +87,27 @@ final class SuggestionsProvider { */ private CompletableFuture provideSuggestions( final StringReader reader, final CommandContextBuilder context) { - final StringRange aliasRange = this.consumeAlias(reader); - final String alias = aliasRange.get(reader); // TODO #toLowerCase and test - final LiteralCommandNode literal = - (LiteralCommandNode) context.getRootNode().getChild(alias); + lock.lock(); + try { + final StringRange aliasRange = this.consumeAlias(reader); + final String alias = aliasRange.get(reader).toLowerCase(Locale.ENGLISH); + final LiteralCommandNode literal = + (LiteralCommandNode) context.getRootNode().getChild(alias); - final boolean hasArguments = reader.canRead(); - if (hasArguments) { - if (literal == null) { - // Input has arguments for non-registered alias - return Suggestions.empty(); + final boolean hasArguments = reader.canRead(); + if (hasArguments) { + if (literal == null) { + // Input has arguments for non-registered alias + return Suggestions.empty(); + } + context.withNode(literal, aliasRange); + reader.skip(); // separator + return this.provideArgumentsSuggestions(literal, reader, context); + } else { + return this.provideAliasSuggestions(reader, context); } - context.withNode(literal, aliasRange); - reader.skip(); // separator - // TODO Provide arguments suggestions - } else { - // TODO Provide alias suggestions + } finally { + lock.unlock(); } } @@ -97,6 +120,19 @@ final class SuggestionsProvider { return range; } + /** + * Returns whether a literal node with the given name should be considered for + * suggestions given the specified input. + * + * @param name the literal name + * @param input the partial input + * @return true if the literal should be considered; false otherwise + */ + private static boolean shouldConsider(final String name, final String input) { + // TODO (perf) If we expect input to be lowercase, no need to ignore case + return name.regionMatches(true, 0, input, 0, input.length()); + } + /** * Returns alias suggestions for the given input. * @@ -110,11 +146,206 @@ final class SuggestionsProvider { final String input = reader.getRead(); final Collection> aliases = contextSoFar.getRootNode().getChildren(); + @SuppressWarnings("unchecked") final CompletableFuture[] futures = new CompletableFuture[aliases.size()]; + int i = 0; + for (final CommandNode node : aliases) { + CompletableFuture future = Suggestions.empty(); + final String alias = node.getName(); + if (shouldConsider(alias, input) && node.canUse(source)) { + final CommandContextBuilder context = contextSoFar.copy() + .withNode(node, ALIAS_SUGGESTION_RANGE); + if (node.canUse(context, reader)) { + // LiteralCommandNode#listSuggestions is case insensitive + final SuggestionsBuilder builder = new SuggestionsBuilder(input, 0); + future = builder.suggest(alias).buildFuture(); + } + } + futures[i++] = future; + } + return this.merge(input, futures); } - /*private RootCommandNode getRoot() { - return this.dispatcher.getRoot(); - }*/ + /** + * Merges the suggestions provided by the {@link Command} associated to the given + * alias node and the hints given during registration for the given input. + * + *

The context is not mutated by this method. The reader's cursor may be modified. + * + * @param alias the alias node + * @param reader the input reader + * @param contextSoFar the context, containing {@code alias} + * @return a future that completes with the suggestions + */ + private CompletableFuture provideArgumentsSuggestions( + final LiteralCommandNode alias, final StringReader reader, + final CommandContextBuilder contextSoFar) { + final S source = contextSoFar.getSource(); + final VelocityArgumentCommandNode argsNode = VelocityCommands.getArgumentsNode(alias); + if (argsNode == null) { + // This is a BrigadierCommand, fallback to regular suggestions + reader.setCursor(0); + final ParseResults parse = this.dispatcher.parse(reader, source); + return this.dispatcher.getCompletionSuggestions(parse); + } + + if (!argsNode.canUse(source)) { + return Suggestions.empty(); + } + + final int start = reader.getCursor(); + final CommandContextBuilder context = contextSoFar.copy(); + try { + argsNode.parse(reader, context); // reads remaining input + } catch (final CommandSyntaxException e) { + throw new RuntimeException(e); + } + + if (!argsNode.canUse(context, reader)) { + return Suggestions.empty(); + } + + // Ask the command for suggestions via the arguments node + reader.setCursor(start); + final CompletableFuture cmdSuggestions = + this.getArgumentsNodeSuggestions(argsNode, reader, context); + final boolean hasHints = alias.getChildren().size() > 1; + if (!hasHints) { + return cmdSuggestions; + } + + // Parse the hint nodes to get remaining suggestions + reader.setCursor(start); + final CompletableFuture hintSuggestions = + this.getHintSuggestions(alias, reader, contextSoFar); + + final String fullInput = reader.getString(); + return this.merge(fullInput, cmdSuggestions, hintSuggestions); + } + + /** + * Returns the suggestions provided by the {@link Command} associated to + * the specified arguments node for the given input. + * + *

The reader and context are not mutated by this method. + * + * @param node the arguments node of the command + * @param reader the input reader + * @param context the context, containing an alias node and {@code node} + * @return a future that completes with the suggestions + */ + private CompletableFuture getArgumentsNodeSuggestions( + final VelocityArgumentCommandNode node, final StringReader reader, + final CommandContextBuilder context) { + final int start = reader.getCursor(); + final String fullInput = reader.getString(); + final CommandContext built = context.build(fullInput); + try { + return node.listSuggestions(built, new SuggestionsBuilder(fullInput, start)); + } catch (final Throwable e) { + // Ugly, ugly swallowing of everything Throwable, because plugins are naughty. + LOGGER.error("Arguments node cannot provide suggestions, skipping", e); + return Suggestions.empty(); + } + } + + /** + * Returns the suggestions provided by the matched hint nodes for the given input. + * + *

The reader and context are not mutated by this method. + * + * @param alias the alias node + * @param reader the input reader + * @param context the context, containing {@code alias} + * @return a future that completes with the suggestions + */ + private CompletableFuture getHintSuggestions( + final LiteralCommandNode alias, final StringReader reader, + final CommandContextBuilder context) { + final ParseResults parse = this.parseHints(alias, reader, context); + return this.dispatcher.getCompletionSuggestions(parse); + } + + /** + * Parses the hint nodes under the given node, which is either an alias node of + * a {@link Command} or another hint node. + * + *

The reader and context are not mutated by this method. + * + * @param node the node to parse + * @param originalReader the input reader + * @param contextSoFar the context, containing the alias node of the command + * @return the parse results containing the parsed hint nodes + */ + private ParseResults parseHints(final CommandNode node, final StringReader originalReader, + final CommandContextBuilder contextSoFar) { + // This is a stripped-down version of CommandDispatcher#parseNodes that doesn't + // check the requirements are satisfied and ignores redirects, neither of which + // are used by hint nodes. Parsing errors are ignored. + List> potentials = null; + for (final CommandNode child : node.getRelevantNodes(originalReader)) { + if (VelocityCommands.isArgumentsNode(child)) { + continue; + } + final CommandContextBuilder context = contextSoFar.copy(); + final StringReader reader = new StringReader(originalReader); + try { + // We intentionally don't catch all unchecked exceptions + child.parse(reader, context); + if (reader.canRead() && reader.peek() != CommandDispatcher.ARGUMENT_SEPARATOR_CHAR) { + continue; + } + } catch (final CommandSyntaxException e) { + continue; + } + if (reader.canRead(2)) { // separator + string + reader.skip(); // separator + final ParseResults parse = this.parseHints(child, reader, context); + if (potentials == null) { + potentials = new ArrayList<>(1); + } + potentials.add(parse); + } + } + if (potentials != null) { + if (potentials.size() > 1) { + potentials.sort((a, b) -> { + if (!a.getReader().canRead() && b.getReader().canRead()) { + return -1; + } + if (a.getReader().canRead() && !b.getReader().canRead()) { + return 1; + } + return 0; + }); + } + return potentials.get(0); + } + return new ParseResults<>(contextSoFar, originalReader, Collections.emptyMap()); + } + + /** + * Returns a future that is completed with the result of merging the {@link Suggestions} + * the given futures complete with. The results of the futures that complete exceptionally + * are ignored. + * + * @param fullInput the command input + * @param futures the futures that complete with the suggestions + * @return the future that completes with the merged suggestions + */ + @SafeVarargs + private CompletableFuture merge( + final String fullInput, final CompletableFuture... futures) { + // https://github.com/Mojang/brigadier/pull/81 + return CompletableFuture.allOf(futures).handle((unused, throwable) -> { + final List suggestions = new ArrayList<>(futures.length); + for (final CompletableFuture future : futures) { + if (!future.isCompletedExceptionally()) { + suggestions.add(future.join()); + } + } + return Suggestions.merge(fullInput, suggestions); + }); + } } diff --git a/proxy/src/main/java/com/velocitypowered/proxy/command/VelocityCommandManager.java b/proxy/src/main/java/com/velocitypowered/proxy/command/VelocityCommandManager.java index 48295a2bb..0d15f02b6 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/command/VelocityCommandManager.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/command/VelocityCommandManager.java @@ -18,40 +18,61 @@ package com.velocitypowered.proxy.command; import com.google.common.base.Preconditions; +import com.google.common.collect.ImmutableList; import com.google.common.collect.Lists; import com.mojang.brigadier.CommandDispatcher; import com.mojang.brigadier.ParseResults; import com.mojang.brigadier.exceptions.CommandSyntaxException; import com.mojang.brigadier.suggestion.Suggestion; -import com.mojang.brigadier.tree.CommandNode; -import com.mojang.brigadier.tree.LiteralCommandNode; +import com.mojang.brigadier.tree.RootCommandNode; import com.velocitypowered.api.command.BrigadierCommand; import com.velocitypowered.api.command.Command; import com.velocitypowered.api.command.CommandManager; import com.velocitypowered.api.command.CommandMeta; import com.velocitypowered.api.command.CommandSource; -import com.velocitypowered.api.command.RawCommand; -import com.velocitypowered.api.command.SimpleCommand; import com.velocitypowered.api.event.command.CommandExecuteEvent; import com.velocitypowered.api.event.command.CommandExecuteEvent.CommandResult; +import com.velocitypowered.proxy.command.registrar.BrigadierCommandRegistrar; +import com.velocitypowered.proxy.command.registrar.CommandRegistrar; +import com.velocitypowered.proxy.command.registrar.RawCommandRegistrar; +import com.velocitypowered.proxy.command.registrar.SimpleCommandRegistrar; import com.velocitypowered.proxy.event.VelocityEventManager; -import com.velocitypowered.proxy.util.BrigadierUtils; -import java.util.Iterator; import java.util.List; import java.util.Locale; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.locks.ReadWriteLock; +import java.util.concurrent.locks.ReentrantReadWriteLock; import net.kyori.adventure.identity.Identity; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.format.NamedTextColor; +import org.checkerframework.checker.lock.qual.GuardedBy; public class VelocityCommandManager implements CommandManager { - private final CommandDispatcher dispatcher; - private final VelocityEventManager eventManager; + private final @GuardedBy("lock") CommandDispatcher dispatcher; + private final ReadWriteLock lock; + private final VelocityEventManager eventManager; + private final List> registrars; + private final SuggestionsProvider suggestionsProvider; + private final CommandGraphInjector injector; + + /** + * Constructs a command manager. + * + * @param eventManager the event manager + */ public VelocityCommandManager(final VelocityEventManager eventManager) { - this.eventManager = Preconditions.checkNotNull(eventManager); + this.lock = new ReentrantReadWriteLock(); this.dispatcher = new CommandDispatcher<>(); + this.eventManager = Preconditions.checkNotNull(eventManager); + final RootCommandNode root = this.dispatcher.getRoot(); + this.registrars = ImmutableList.of( + new BrigadierCommandRegistrar(root, this.lock.writeLock()), + new SimpleCommandRegistrar(root, this.lock.writeLock()), + new RawCommandRegistrar(root, this.lock.writeLock())); + this.suggestionsProvider = new SuggestionsProvider<>(this.dispatcher, this.lock.readLock()); + this.injector = new CommandGraphInjector<>(this.dispatcher, this.lock.readLock()); } @Override @@ -77,42 +98,34 @@ public class VelocityCommandManager implements CommandManager { Preconditions.checkNotNull(meta, "meta"); Preconditions.checkNotNull(command, "command"); - Iterator aliasIterator = meta.getAliases().iterator(); - String primaryAlias = aliasIterator.next(); - - LiteralCommandNode node = null; - if (command instanceof BrigadierCommand) { - node = ((BrigadierCommand) command).getNode(); - } else if (command instanceof SimpleCommand) { - node = CommandNodeFactory.SIMPLE.create(primaryAlias, (SimpleCommand) command); - } else if (command instanceof RawCommand) { - node = CommandNodeFactory.RAW.create(primaryAlias, (RawCommand) command); - } else { - throw new IllegalArgumentException("Unknown command implementation for " - + command.getClass().getName()); - } - - if (!(command instanceof BrigadierCommand)) { - for (CommandNode hint : meta.getHints()) { - node.addChild(BrigadierUtils.wrapForHinting(hint, node.getCommand())); + for (final CommandRegistrar registrar : this.registrars) { + if (this.tryRegister(registrar, command, meta)) { + return; } } + throw new IllegalArgumentException( + command + " does not implement a registrable Command subinterface"); + } - dispatcher.getRoot().addChild(node); - while (aliasIterator.hasNext()) { - String currentAlias = aliasIterator.next(); - CommandNode existingNode = dispatcher.getRoot() - .getChild(currentAlias.toLowerCase(Locale.ENGLISH)); - if (existingNode != null) { - dispatcher.getRoot().getChildren().remove(existingNode); - } - dispatcher.getRoot().addChild(BrigadierUtils.buildRedirect(currentAlias, node)); + private boolean tryRegister(final CommandRegistrar registrar, + final Command command, final CommandMeta meta) { + final Class superInterface = registrar.registrableSuperInterface(); + if (!superInterface.isInstance(command)) { + return false; + } + try { + registrar.register(superInterface.cast(command), meta); + return true; + } catch (final IllegalArgumentException ignored) { + return false; } } @Override public void unregister(final String alias) { Preconditions.checkNotNull(alias, "alias"); + // The literals of secondary aliases will preserve the children of + // the removed literal in the graph. dispatcher.getRoot().removeChildByName(alias.toLowerCase(Locale.ENGLISH)); } @@ -134,9 +147,10 @@ public class VelocityCommandManager implements CommandManager { Preconditions.checkNotNull(source, "source"); Preconditions.checkNotNull(cmdLine, "cmdLine"); - ParseResults results = parse(cmdLine, source, true); + final String normalizedInput = VelocityCommands.normalizeInput(cmdLine, true); + final ParseResults parse = this.parse(normalizedInput, source); try { - return dispatcher.execute(results) != BrigadierCommand.FORWARD; + return dispatcher.execute(parse) != BrigadierCommand.FORWARD; } catch (final CommandSyntaxException e) { boolean isSyntaxError = !e.getType().equals( CommandSyntaxException.BUILT_IN_EXCEPTIONS.dispatcherUnknownCommand()); @@ -190,22 +204,32 @@ public class VelocityCommandManager implements CommandManager { Preconditions.checkNotNull(source, "source"); Preconditions.checkNotNull(cmdLine, "cmdLine"); - ParseResults parse = parse(cmdLine, source, false); - return dispatcher.getCompletionSuggestions(parse) - .thenApply(suggestions -> Lists.transform(suggestions.getList(), Suggestion::getText)); + final String normalizedInput = VelocityCommands.normalizeInput(cmdLine, false); + return suggestionsProvider.provideSuggestions(normalizedInput, source) + .thenApply(suggestions -> Lists.transform(suggestions.getList(), Suggestion::getText)); } - private ParseResults parse(final String cmdLine, final CommandSource source, - final boolean trim) { - String normalized = BrigadierUtils.normalizeInput(cmdLine, trim); - return dispatcher.parse(normalized, source); + /** + * Parses the given command input. + * + * @param input the normalized command input, without the leading slash ('/') + * @param source the command source to parse the command for + * @return the parse results + */ + private ParseResults parse(final String input, final CommandSource source) { + lock.readLock().lock(); + try { + return dispatcher.parse(input, source); + } finally { + lock.readLock().unlock(); + } } /** * Returns whether the given alias is registered on this manager. * * @param alias the command alias to check - * @return {@code true} if the alias is registered + * @return true if the alias is registered; false otherwise */ @Override public boolean hasCommand(final String alias) { @@ -214,6 +238,11 @@ public class VelocityCommandManager implements CommandManager { } public CommandDispatcher getDispatcher() { + // TODO Can we remove this? This is only used by tests, and constitutes unsafe publication. return dispatcher; } + + public CommandGraphInjector getInjector() { + return injector; + } } \ No newline at end of file diff --git a/proxy/src/main/java/com/velocitypowered/proxy/command/VelocityCommandMeta.java b/proxy/src/main/java/com/velocitypowered/proxy/command/VelocityCommandMeta.java index c45b51080..b3449e4f3 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/command/VelocityCommandMeta.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/command/VelocityCommandMeta.java @@ -20,15 +20,18 @@ package com.velocitypowered.proxy.command; import com.google.common.base.Preconditions; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableSet; +import com.mojang.brigadier.builder.ArgumentBuilder; import com.mojang.brigadier.tree.CommandNode; +import com.velocitypowered.api.command.Command; import com.velocitypowered.api.command.CommandMeta; import com.velocitypowered.api.command.CommandSource; import java.util.Collection; import java.util.List; import java.util.Locale; import java.util.Set; +import java.util.stream.Stream; -final class VelocityCommandMeta implements CommandMeta { +public final class VelocityCommandMeta implements CommandMeta { static final class Builder implements CommandMeta.Builder { @@ -46,9 +49,9 @@ final class VelocityCommandMeta implements CommandMeta { public CommandMeta.Builder aliases(final String... aliases) { Preconditions.checkNotNull(aliases, "aliases"); for (int i = 0, length = aliases.length; i < length; i++) { - final String alias1 = aliases[i]; - Preconditions.checkNotNull(alias1, "alias at index %s", i); - this.aliases.add(alias1.toLowerCase(Locale.ENGLISH)); + final String alias = aliases[i]; + Preconditions.checkNotNull(alias, "alias at index %s", i); + this.aliases.add(alias.toLowerCase(Locale.ENGLISH)); } return this; } @@ -56,16 +59,56 @@ final class VelocityCommandMeta implements CommandMeta { @Override public CommandMeta.Builder hint(final CommandNode node) { Preconditions.checkNotNull(node, "node"); - hints.add(node); + if (node.getCommand() != null) { + throw new IllegalArgumentException("Cannot use executable node for hinting"); + } + if (node.getRedirect() != null) { + throw new IllegalArgumentException("Cannot use a node with a redirect for hinting"); + } + this.hints.add(node); return this; } @Override public CommandMeta build() { - return new VelocityCommandMeta(aliases.build(), hints.build()); + return new VelocityCommandMeta(this.aliases.build(), this.hints.build()); } } + /** + * Creates a node to use for hinting the arguments of a {@link Command}. Hint nodes are + * sent to 1.13+ clients and the proxy uses them for providing suggestions. + * + *

A hint node is used to provide suggestions if and only if the requirements of + * the corresponding {@link CommandNode} are satisfied. The requirement predicate + * of the returned node always returns {@code false}. + * + * @param hint the node containing hinting metadata + * @return the hinting command node + */ + private static CommandNode copyForHinting(final CommandNode hint) { + // We need to perform a deep copy of the hint to prevent the user + // from modifying the nodes and adding a Command or a redirect. + final ArgumentBuilder builder = hint.createBuilder() + // Requirement checking is performed by SuggestionProvider + .requires(source -> false); + for (final CommandNode child : hint.getChildren()) { + builder.then(copyForHinting(child)); + } + return builder.build(); + } + + /** + * Returns a stream of copies of every hint contained in the given metadata object. + * + * @param meta the command metadata + * @return a stream of hinting nodes + */ + // This is a static method because most methods take a CommandMeta. + public static Stream> copyHints(final CommandMeta meta) { + return meta.getHints().stream().map(VelocityCommandMeta::copyForHinting); + } + private final Set aliases; private final List> hints; @@ -77,11 +120,35 @@ final class VelocityCommandMeta implements CommandMeta { @Override public Collection getAliases() { - return aliases; + return this.aliases; } @Override public Collection> getHints() { - return hints; + return this.hints; + } + + @Override + public boolean equals(final Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + + final VelocityCommandMeta that = (VelocityCommandMeta) o; + + if (!this.aliases.equals(that.aliases)) { + return false; + } + return this.hints.equals(that.hints); + } + + @Override + public int hashCode() { + int result = this.aliases.hashCode(); + result = 31 * result + this.hints.hashCode(); + return result; } } diff --git a/proxy/src/main/java/com/velocitypowered/proxy/command/VelocityCommands.java b/proxy/src/main/java/com/velocitypowered/proxy/command/VelocityCommands.java new file mode 100644 index 000000000..9d903aa40 --- /dev/null +++ b/proxy/src/main/java/com/velocitypowered/proxy/command/VelocityCommands.java @@ -0,0 +1,174 @@ +package com.velocitypowered.proxy.command; + +import com.google.common.base.Preconditions; +import com.mojang.brigadier.CommandDispatcher; +import com.mojang.brigadier.builder.LiteralArgumentBuilder; +import com.mojang.brigadier.context.CommandContext; +import com.mojang.brigadier.context.CommandContextBuilder; +import com.mojang.brigadier.context.ParsedArgument; +import com.mojang.brigadier.context.ParsedCommandNode; +import com.mojang.brigadier.tree.CommandNode; +import com.mojang.brigadier.tree.LiteralCommandNode; +import com.mojang.brigadier.tree.RootCommandNode; +import com.velocitypowered.api.command.Command; +import com.velocitypowered.api.command.CommandManager; +import com.velocitypowered.api.command.CommandSource; +import com.velocitypowered.api.command.InvocableCommand; +import com.velocitypowered.proxy.command.brigadier.VelocityArgumentCommandNode; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import org.checkerframework.checker.nullness.qual.Nullable; + +/** + * Provides utility methods common to most {@link Command} implementations. + * In particular, {@link InvocableCommand} implementations use the same logic for + * creating and parsing the alias and arguments command nodes, which is contained + * in this class. + */ +public final class VelocityCommands { + + // Normalization + + /** + * Normalizes the given command input. + * + * @param input the raw command input, without the leading slash ('/') + * @param trim whether to remove leading and trailing whitespace from the input + * @return the normalized command input + */ + static String normalizeInput(final String input, final boolean trim) { + final String command = trim ? input.trim() : input; + int firstSep = command.indexOf(CommandDispatcher.ARGUMENT_SEPARATOR_CHAR); + if (firstSep != -1) { + // Aliases are case-insensitive, arguments are not + return command.substring(0, firstSep).toLowerCase(Locale.ENGLISH) + + command.substring(firstSep); + } else { + return command.toLowerCase(Locale.ENGLISH); + } + } + + // Parsing + + /** + * Returns the parsed alias, used to execute the command. + * + * @param nodes the list of parsed nodes, as returned by {@link CommandContext#getNodes()} or + * {@link CommandContextBuilder#getNodes()} + * @return the command alias + */ + public static String readAlias(final List> nodes) { + if (nodes.isEmpty()) { + throw new IllegalArgumentException("Cannot read alias from empty node list"); + } + return nodes.get(0).getNode().getName(); + } + + public static final String ARGS_NODE_NAME = "arguments"; + + /** + * Returns the parsed arguments that come after the command alias, or {@code fallback} if + * no arguments were provided. + * + * @param arguments the map of parsed arguments, as returned by + * {@link CommandContext#getArguments()} or {@link CommandContextBuilder#getArguments()} + * @param type the type class of the arguments + * @param fallback the value to return if no arguments were provided + * @param the type of the arguments + * @return the command arguments + */ + public static V readArguments(final Map> arguments, + final Class type, final V fallback) { + final ParsedArgument argument = arguments.get(ARGS_NODE_NAME); + if (argument == null) { + return fallback; // either no arguments were given or this isn't an InvocableCommand + } + final Object result = argument.getResult(); + try { + return type.cast(result); + } catch (final ClassCastException e) { + throw new IllegalArgumentException("Parsed argument is of type " + result.getClass() + + ", expected " + type, e); + } + } + + // Alias nodes + + /** + * Returns whether a literal node with the given name can be added to + * the {@link RootCommandNode} associated to a {@link CommandManager}. + * + *

This is an internal method and should not be used in user-facing + * methods. Instead, they should lowercase the given aliases themselves. + * + * @param alias the alias to check + * @return true if the alias can be registered; false otherwise + */ + public static boolean isValidAlias(final String alias) { + return alias.equals(alias.toLowerCase(Locale.ENGLISH)); + } + + /** + * Creates a copy of the given literal with the specified name. + * + * @param original the literal node to copy + * @param newName the name of the returned literal node + * @return a copy of the literal with the given name + */ + public static LiteralCommandNode shallowCopy( + final LiteralCommandNode original, final String newName) { + // Brigadier resolves the redirect of a node if further input can be parsed. + // Let be a literal node having a redirect to a literal. Then, + // the context returned by CommandDispatcher#parseNodes when given the input + // string " " does not contain a child context with as its root node. + // Thus, the vanilla client asks the children of for suggestions, instead + // of those of (https://github.com/Mojang/brigadier/issues/46). + // Perform a shallow copy of the literal instead. + Preconditions.checkNotNull(original, "original"); + Preconditions.checkNotNull(newName, "secondaryAlias"); + final LiteralArgumentBuilder builder = LiteralArgumentBuilder + .literal(newName) + .requires(original.getRequirement()) + .requiresWithContext(original.getContextRequirement()) + .forward(original.getRedirect(), original.getRedirectModifier(), original.isFork()) + .executes(original.getCommand()); + for (final CommandNode child : original.getChildren()) { + builder.then(child); + } + return builder.build(); + } + + // Arguments node + + /** + * Returns the arguments node for the command represented by the given alias node, + * if present; otherwise returns {@code null}. + * + * @param alias the alias node + * @param the type of the command source + * @return the arguments node, or null if not present + */ + static @Nullable VelocityArgumentCommandNode getArgumentsNode( + final LiteralCommandNode alias) { + final CommandNode node = alias.getChild(ARGS_NODE_NAME); + if (node instanceof VelocityArgumentCommandNode) { + return (VelocityArgumentCommandNode) node; + } + return null; + } + + /** + * Returns whether the given node is an arguments node. + * + * @param node the node to check + * @return true if the node is an arguments node; false otherwise + */ + public static boolean isArgumentsNode(final CommandNode node) { + return node instanceof VelocityArgumentCommandNode && node.getName().equals(ARGS_NODE_NAME); + } + + private VelocityCommands() { + throw new AssertionError(); + } +} diff --git a/proxy/src/main/java/com/velocitypowered/proxy/command/VelocityRawCommandInvocation.java b/proxy/src/main/java/com/velocitypowered/proxy/command/VelocityRawCommandInvocation.java deleted file mode 100644 index 3e41c4436..000000000 --- a/proxy/src/main/java/com/velocitypowered/proxy/command/VelocityRawCommandInvocation.java +++ /dev/null @@ -1,54 +0,0 @@ -/* - * Copyright (C) 2018 Velocity Contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package com.velocitypowered.proxy.command; - -import com.google.common.base.Preconditions; -import com.mojang.brigadier.context.CommandContext; -import com.velocitypowered.api.command.CommandSource; -import com.velocitypowered.api.command.RawCommand; -import com.velocitypowered.proxy.util.BrigadierUtils; - -final class VelocityRawCommandInvocation extends AbstractCommandInvocation - implements RawCommand.Invocation { - - static final Factory FACTORY = new Factory(); - - static class Factory implements CommandInvocationFactory { - - @Override - public RawCommand.Invocation create(final CommandContext context) { - return new VelocityRawCommandInvocation( - context.getSource(), - BrigadierUtils.getAlias(context), - BrigadierUtils.getRawArguments(context)); - } - } - - private final String alias; - - private VelocityRawCommandInvocation(final CommandSource source, - final String alias, final String arguments) { - super(source, arguments); - this.alias = Preconditions.checkNotNull(alias); - } - - @Override - public String alias() { - return alias; - } -} diff --git a/proxy/src/main/java/com/velocitypowered/proxy/command/VelocitySimpleCommandInvocation.java b/proxy/src/main/java/com/velocitypowered/proxy/command/VelocitySimpleCommandInvocation.java deleted file mode 100644 index 74d851c88..000000000 --- a/proxy/src/main/java/com/velocitypowered/proxy/command/VelocitySimpleCommandInvocation.java +++ /dev/null @@ -1,52 +0,0 @@ -/* - * Copyright (C) 2018 Velocity Contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package com.velocitypowered.proxy.command; - -import com.mojang.brigadier.context.CommandContext; -import com.velocitypowered.api.command.CommandSource; -import com.velocitypowered.api.command.SimpleCommand; -import com.velocitypowered.proxy.util.BrigadierUtils; - -final class VelocitySimpleCommandInvocation extends AbstractCommandInvocation - implements SimpleCommand.Invocation { - - static final Factory FACTORY = new Factory(); - - static class Factory implements CommandInvocationFactory { - - @Override - public SimpleCommand.Invocation create(final CommandContext context) { - final String[] arguments = BrigadierUtils.getSplitArguments(context); - final String alias = BrigadierUtils.getAlias(context); - return new VelocitySimpleCommandInvocation(context.getSource(), alias, arguments); - } - } - - private final String alias; - - VelocitySimpleCommandInvocation(final CommandSource source, final String alias, - final String[] arguments) { - super(source, arguments); - this.alias = alias; - } - - @Override - public String alias() { - return this.alias; - } -} diff --git a/proxy/src/main/java/com/velocitypowered/proxy/command/brigadier/StringArrayArgumentType.java b/proxy/src/main/java/com/velocitypowered/proxy/command/brigadier/StringArrayArgumentType.java new file mode 100644 index 000000000..4b482a909 --- /dev/null +++ b/proxy/src/main/java/com/velocitypowered/proxy/command/brigadier/StringArrayArgumentType.java @@ -0,0 +1,46 @@ +package com.velocitypowered.proxy.command.brigadier; + +import com.google.common.base.Splitter; +import com.mojang.brigadier.CommandDispatcher; +import com.mojang.brigadier.StringReader; +import com.mojang.brigadier.arguments.ArgumentType; +import com.mojang.brigadier.exceptions.CommandSyntaxException; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; + +/** + * An argument type that parses the remaining contents of a {@link StringReader}, + * splitting the input into words and placing the results in a string array. + */ +public final class StringArrayArgumentType implements ArgumentType { + + public static final StringArrayArgumentType INSTANCE = new StringArrayArgumentType(); + public static final String[] EMPTY = new String[0]; + + private static final Splitter WORD_SPLITTER = + Splitter.on(CommandDispatcher.ARGUMENT_SEPARATOR_CHAR); + private static final List EXAMPLES = Arrays.asList("word", "some words"); + + private StringArrayArgumentType() {} + + @Override + public String[] parse(final StringReader reader) throws CommandSyntaxException { + final String text = reader.getRemaining(); + reader.setCursor(reader.getTotalLength()); + if (text.isEmpty()) { + return EMPTY; + } + return WORD_SPLITTER.splitToList(text).toArray(EMPTY); + } + + @Override + public String toString() { + return "stringArray()"; + } + + @Override + public Collection getExamples() { + return EXAMPLES; + } +} diff --git a/proxy/src/main/java/com/velocitypowered/proxy/command/brigadier/VelocityArgumentBuilder.java b/proxy/src/main/java/com/velocitypowered/proxy/command/brigadier/VelocityArgumentBuilder.java new file mode 100644 index 000000000..3f1c1956a --- /dev/null +++ b/proxy/src/main/java/com/velocitypowered/proxy/command/brigadier/VelocityArgumentBuilder.java @@ -0,0 +1,61 @@ +package com.velocitypowered.proxy.command.brigadier; + +import com.google.common.base.Preconditions; +import com.mojang.brigadier.arguments.ArgumentType; +import com.mojang.brigadier.builder.ArgumentBuilder; +import com.mojang.brigadier.suggestion.SuggestionProvider; +import com.mojang.brigadier.tree.CommandNode; +import org.checkerframework.checker.nullness.qual.Nullable; + +/** + * A builder for creating {@link VelocityArgumentCommandNode}s. + * + * @param the type of the command source + * @param the type of the argument to parse + */ +public final class VelocityArgumentBuilder + extends ArgumentBuilder> { + + public static VelocityArgumentBuilder velocityArgument(final String name, + final ArgumentType type) { + Preconditions.checkNotNull(name, "name"); + Preconditions.checkNotNull(type, "type"); + return new VelocityArgumentBuilder<>(name, type); + } + + private final String name; + private final ArgumentType type; + private SuggestionProvider suggestionsProvider = null; + + public VelocityArgumentBuilder(final String name, final ArgumentType type) { + this.name = name; + this.type = type; + } + + public VelocityArgumentBuilder suggests(final @Nullable SuggestionProvider provider) { + this.suggestionsProvider = provider; + return this; + } + + @Override + public VelocityArgumentBuilder then(final ArgumentBuilder argument) { + throw new UnsupportedOperationException("Cannot add children to a greedy node"); + } + + @Override + public VelocityArgumentBuilder then(final CommandNode argument) { + throw new UnsupportedOperationException("Cannot add children to a greedy node"); + } + + @Override + protected VelocityArgumentBuilder getThis() { + return this; + } + + @Override + public VelocityArgumentCommandNode build() { + return new VelocityArgumentCommandNode<>(this.name, this.type, getCommand(), getRequirement(), + getContextRequirement(), getRedirect(), getRedirectModifier(), isFork(), + this.suggestionsProvider); + } +} diff --git a/proxy/src/main/java/com/velocitypowered/proxy/command/brigadier/VelocityArgumentCommandNode.java b/proxy/src/main/java/com/velocitypowered/proxy/command/brigadier/VelocityArgumentCommandNode.java new file mode 100644 index 000000000..f8c0299c4 --- /dev/null +++ b/proxy/src/main/java/com/velocitypowered/proxy/command/brigadier/VelocityArgumentCommandNode.java @@ -0,0 +1,121 @@ +package com.velocitypowered.proxy.command.brigadier; + +import com.google.common.base.Preconditions; +import com.mojang.brigadier.Command; +import com.mojang.brigadier.ImmutableStringReader; +import com.mojang.brigadier.RedirectModifier; +import com.mojang.brigadier.StringReader; +import com.mojang.brigadier.arguments.ArgumentType; +import com.mojang.brigadier.arguments.StringArgumentType; +import com.mojang.brigadier.builder.RequiredArgumentBuilder; +import com.mojang.brigadier.context.CommandContext; +import com.mojang.brigadier.context.CommandContextBuilder; +import com.mojang.brigadier.context.ParsedArgument; +import com.mojang.brigadier.exceptions.CommandSyntaxException; +import com.mojang.brigadier.suggestion.SuggestionProvider; +import com.mojang.brigadier.suggestion.Suggestions; +import com.mojang.brigadier.suggestion.SuggestionsBuilder; +import com.mojang.brigadier.tree.ArgumentCommandNode; +import com.mojang.brigadier.tree.CommandNode; +import java.util.Collection; +import java.util.concurrent.CompletableFuture; +import java.util.function.BiPredicate; +import java.util.function.Predicate; + +/** + * An argument node that uses the given (possibly custom) {@link ArgumentType} + * for parsing, while maintaining compatibility with the vanilla client. + * The argument type must be greedy and accept any input. + * + * @param the type of the command source + * @param the type of the argument to parse + */ +public class VelocityArgumentCommandNode extends ArgumentCommandNode { + + private final ArgumentType type; + + public VelocityArgumentCommandNode( + final String name, final ArgumentType type, final Command command, + final Predicate requirement, + final BiPredicate, ImmutableStringReader> contextRequirement, + final CommandNode redirect, final RedirectModifier modifier, final boolean forks, + final SuggestionProvider customSuggestions) { + super(name, StringArgumentType.greedyString(), command, requirement, contextRequirement, + redirect, modifier, forks, customSuggestions); + this.type = Preconditions.checkNotNull(type, "type"); + } + + @Override + public void parse(final StringReader reader, final CommandContextBuilder contextBuilder) + throws CommandSyntaxException { + // Same as super, except we use the rich ArgumentType + final int start = reader.getCursor(); + final T result = this.type.parse(reader); + if (reader.canRead()) { + throw CommandSyntaxException.BUILT_IN_EXCEPTIONS.dispatcherParseException() + .createWithContext(reader, "Expected greedy ArgumentType to parse all input"); + } + + final ParsedArgument parsed = new ParsedArgument<>(start, reader.getCursor(), result); + contextBuilder.withArgument(getName(), parsed); + contextBuilder.withNode(this, parsed.getRange()); + } + + @Override + public CompletableFuture listSuggestions( + final CommandContext context, final SuggestionsBuilder builder) + throws CommandSyntaxException { + if (getCustomSuggestions() == null) { + return Suggestions.empty(); + } + return getCustomSuggestions().getSuggestions(context, builder); + } + + @Override + public RequiredArgumentBuilder createBuilder() { + throw new UnsupportedOperationException(); + } + + @Override + public boolean isValidInput(final String input) { + return true; + } + + @Override + public void addChild(final CommandNode node) { + throw new UnsupportedOperationException("Cannot add children to a greedy node"); + } + + @Override + public boolean equals(final Object o) { + if (this == o) { + return true; + } + if (!(o instanceof VelocityArgumentCommandNode)) { + return false; + } + if (!super.equals(o)) { + return false; + } + + final VelocityArgumentCommandNode that = (VelocityArgumentCommandNode) o; + return this.type.equals(that.type); + } + + @Override + public int hashCode() { + int result = super.hashCode(); + result = 31 * result + this.type.hashCode(); + return result; + } + + @Override + public Collection getExamples() { + return this.type.getExamples(); + } + + @Override + public String toString() { + return ""; + } +} diff --git a/proxy/src/main/java/com/velocitypowered/proxy/command/AbstractCommandInvocation.java b/proxy/src/main/java/com/velocitypowered/proxy/command/invocation/AbstractCommandInvocation.java similarity index 69% rename from proxy/src/main/java/com/velocitypowered/proxy/command/AbstractCommandInvocation.java rename to proxy/src/main/java/com/velocitypowered/proxy/command/invocation/AbstractCommandInvocation.java index 87bd8e496..43fff5b07 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/command/AbstractCommandInvocation.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/command/invocation/AbstractCommandInvocation.java @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package com.velocitypowered.proxy.command; +package com.velocitypowered.proxy.command.invocation; import com.google.common.base.Preconditions; import com.velocitypowered.api.command.CommandInvocation; @@ -38,11 +38,35 @@ abstract class AbstractCommandInvocation implements CommandInvocation { @Override public CommandSource source() { - return source; + return this.source; } @Override public T arguments() { - return arguments; + return this.arguments; + } + + @Override + public boolean equals(final Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + + final AbstractCommandInvocation that = (AbstractCommandInvocation) o; + + if (!this.source.equals(that.source)) { + return false; + } + return this.arguments.equals(that.arguments); + } + + @Override + public int hashCode() { + int result = this.source.hashCode(); + result = 31 * result + this.arguments.hashCode(); + return result; } } diff --git a/proxy/src/main/java/com/velocitypowered/proxy/command/invocation/CommandInvocationFactory.java b/proxy/src/main/java/com/velocitypowered/proxy/command/invocation/CommandInvocationFactory.java new file mode 100644 index 000000000..c462a01ce --- /dev/null +++ b/proxy/src/main/java/com/velocitypowered/proxy/command/invocation/CommandInvocationFactory.java @@ -0,0 +1,78 @@ +/* + * Copyright (C) 2018 Velocity Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.velocitypowered.proxy.command.invocation; + +import com.mojang.brigadier.context.CommandContext; +import com.mojang.brigadier.context.CommandContextBuilder; +import com.mojang.brigadier.context.ParsedArgument; +import com.mojang.brigadier.context.ParsedCommandNode; +import com.velocitypowered.api.command.CommandInvocation; +import com.velocitypowered.api.command.CommandSource; +import java.util.List; +import java.util.Map; + +/** + * Creates command invocation objects from a command context builder or + * a command context. + * + *

Let {@code builder} be a command context builder, and {@code context} + * a context returned by calling {@link CommandContextBuilder#build(String)} on + * {@code builder}. The invocations returned by {@link #create(CommandContext)} + * when given {@code context}, and {@link #create(CommandContextBuilder)} when + * given {@code builder} are equal. + * + * @param the type of the built invocation + */ +public interface CommandInvocationFactory> { + + /** + * Creates an invocation from the given command context. + * + * @param context the command context + * @return the built invocation context + */ + default I create(final CommandContext context) { + return this.create(context.getSource(), context.getNodes(), context.getArguments()); + } + + /** + * Creates an invocation from the given command context builder. + * + * @param context the command context builder + * @return the built invocation context + */ + default I create(final CommandContextBuilder context) { + return this.create(context.getSource(), context.getNodes(), context.getArguments()); + } + + /** + * Creates an invocation from the given parsed nodes and arguments. + * + * @param source the command source + * @param nodes the list of parsed nodes, as returned by {@link CommandContext#getNodes()} and + * {@link CommandContextBuilder#getNodes()} + * @param arguments the list of parsed arguments, as returned by + * {@link CommandContext#getArguments()} and {@link CommandContextBuilder#getArguments()} + * @return the built invocation context + */ + // This provides an abstraction over methods common to CommandContext and CommandContextBuilder. + // Annoyingly, they mostly have the same getters but one is (correctly) not a subclass of + // the other. Subclasses may override the methods above to obtain class-specific data. + I create(final CommandSource source, final List> nodes, + final Map> arguments); +} diff --git a/proxy/src/main/java/com/velocitypowered/proxy/command/invocation/RawCommandInvocation.java b/proxy/src/main/java/com/velocitypowered/proxy/command/invocation/RawCommandInvocation.java new file mode 100644 index 000000000..2579a2e65 --- /dev/null +++ b/proxy/src/main/java/com/velocitypowered/proxy/command/invocation/RawCommandInvocation.java @@ -0,0 +1,90 @@ +/* + * Copyright (C) 2018 Velocity Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.velocitypowered.proxy.command.invocation; + +import com.google.common.base.Preconditions; +import com.mojang.brigadier.context.ParsedArgument; +import com.mojang.brigadier.context.ParsedCommandNode; +import com.velocitypowered.api.command.CommandSource; +import com.velocitypowered.api.command.RawCommand; +import com.velocitypowered.proxy.command.VelocityCommands; +import java.util.List; +import java.util.Map; + +public final class RawCommandInvocation extends AbstractCommandInvocation + implements RawCommand.Invocation { + + public static final Factory FACTORY = new Factory(); + + private static class Factory implements CommandInvocationFactory { + + @Override + public RawCommand.Invocation create( + final CommandSource source, final List> nodes, + final Map> arguments) { + final String alias = VelocityCommands.readAlias(nodes); + final String args = VelocityCommands.readArguments(arguments, String.class, ""); + return new RawCommandInvocation(source, alias, args); + } + } + + private final String alias; + + private RawCommandInvocation(final CommandSource source, + final String alias, final String arguments) { + super(source, arguments); + this.alias = Preconditions.checkNotNull(alias, "alias"); + } + + @Override + public String alias() { + return this.alias; + } + + @Override + public boolean equals(final Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + if (!super.equals(o)) { + return false; + } + + final RawCommandInvocation that = (RawCommandInvocation) o; + return this.alias.equals(that.alias); + } + + @Override + public int hashCode() { + int result = super.hashCode(); + result = 31 * result + this.alias.hashCode(); + return result; + } + + @Override + public String toString() { + return "RawCommandInvocation{" + + "source='" + this.source() + '\'' + + ", alias='" + this.alias + '\'' + + ", arguments='" + this.arguments() + '\'' + + '}'; + } +} diff --git a/proxy/src/main/java/com/velocitypowered/proxy/command/invocation/SimpleCommandInvocation.java b/proxy/src/main/java/com/velocitypowered/proxy/command/invocation/SimpleCommandInvocation.java new file mode 100644 index 000000000..a6ec60611 --- /dev/null +++ b/proxy/src/main/java/com/velocitypowered/proxy/command/invocation/SimpleCommandInvocation.java @@ -0,0 +1,93 @@ +/* + * Copyright (C) 2018 Velocity Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.velocitypowered.proxy.command.invocation; + +import com.google.common.base.Preconditions; +import com.mojang.brigadier.context.ParsedArgument; +import com.mojang.brigadier.context.ParsedCommandNode; +import com.velocitypowered.api.command.CommandSource; +import com.velocitypowered.api.command.SimpleCommand; +import com.velocitypowered.proxy.command.VelocityCommands; +import com.velocitypowered.proxy.command.brigadier.StringArrayArgumentType; +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +public final class SimpleCommandInvocation extends AbstractCommandInvocation + implements SimpleCommand.Invocation { + + public static final Factory FACTORY = new Factory(); + + private static class Factory implements CommandInvocationFactory { + + @Override + public SimpleCommand.Invocation create( + final CommandSource source, final List> nodes, + final Map> arguments) { + final String alias = VelocityCommands.readAlias(nodes); + final String[] args = VelocityCommands.readArguments( + arguments, String[].class, StringArrayArgumentType.EMPTY); + return new SimpleCommandInvocation(source, alias, args); + } + } + + private final String alias; + + SimpleCommandInvocation(final CommandSource source, final String alias, + final String[] arguments) { + super(source, arguments); + this.alias = Preconditions.checkNotNull(alias, "alias"); + } + + @Override + public String alias() { + return this.alias; + } + + @Override + public boolean equals(final Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + if (!super.equals(o)) { + return false; + } + + final SimpleCommandInvocation that = (SimpleCommandInvocation) o; + return this.alias.equals(that.alias); + } + + @Override + public int hashCode() { + int result = super.hashCode(); + result = 31 * result + this.alias.hashCode(); + return result; + } + + @Override + public String toString() { + return "SimpleCommandInvocation{" + + "source='" + this.source() + '\'' + + ", alias='" + this.alias + '\'' + + ", arguments='" + Arrays.toString(this.arguments()) + '\'' + + '}'; + } +} diff --git a/proxy/src/main/java/com/velocitypowered/proxy/command/registrar/AbstractCommandRegistrar.java b/proxy/src/main/java/com/velocitypowered/proxy/command/registrar/AbstractCommandRegistrar.java new file mode 100644 index 000000000..c4a767be1 --- /dev/null +++ b/proxy/src/main/java/com/velocitypowered/proxy/command/registrar/AbstractCommandRegistrar.java @@ -0,0 +1,44 @@ +package com.velocitypowered.proxy.command.registrar; + +import com.google.common.base.Preconditions; +import com.mojang.brigadier.tree.LiteralCommandNode; +import com.mojang.brigadier.tree.RootCommandNode; +import com.velocitypowered.api.command.Command; +import com.velocitypowered.api.command.CommandSource; +import com.velocitypowered.proxy.command.VelocityCommands; +import java.util.concurrent.locks.Lock; +import org.checkerframework.checker.lock.qual.GuardedBy; + +/** + * Base class for {@link CommandRegistrar} implementations. + * + * @param the type of the command to register + */ +abstract class AbstractCommandRegistrar implements CommandRegistrar { + + private final @GuardedBy("lock") RootCommandNode root; + private final Lock lock; + + protected AbstractCommandRegistrar(final RootCommandNode root, final Lock lock) { + this.root = Preconditions.checkNotNull(root, "root"); + this.lock = Preconditions.checkNotNull(lock, "lock"); + } + + protected void register(final LiteralCommandNode node) { + lock.lock(); + try { + // Registration overrides previous aliased command + root.removeChildByName(node.getName()); + root.addChild(node); + } finally { + lock.unlock(); + } + } + + protected void register(final LiteralCommandNode node, + final String secondaryAlias) { + final LiteralCommandNode copy = + VelocityCommands.shallowCopy(node, secondaryAlias); + this.register(copy); + } +} diff --git a/proxy/src/main/java/com/velocitypowered/proxy/command/registrar/BrigadierCommandRegistrar.java b/proxy/src/main/java/com/velocitypowered/proxy/command/registrar/BrigadierCommandRegistrar.java new file mode 100644 index 000000000..66ce113a2 --- /dev/null +++ b/proxy/src/main/java/com/velocitypowered/proxy/command/registrar/BrigadierCommandRegistrar.java @@ -0,0 +1,46 @@ +package com.velocitypowered.proxy.command.registrar; + +import com.mojang.brigadier.tree.LiteralCommandNode; +import com.mojang.brigadier.tree.RootCommandNode; +import com.velocitypowered.api.command.BrigadierCommand; +import com.velocitypowered.api.command.CommandMeta; +import com.velocitypowered.api.command.CommandSource; +import com.velocitypowered.proxy.command.VelocityCommands; +import java.util.concurrent.locks.Lock; + +/** + * Registers {@link BrigadierCommand}s in a root node. + */ +public final class BrigadierCommandRegistrar extends AbstractCommandRegistrar { + + public BrigadierCommandRegistrar(final RootCommandNode root, final Lock lock) { + super(root, lock); + } + + @Override + public void register(final BrigadierCommand command, final CommandMeta meta) { + // The literal name might not match any aliases on the given meta. + // Register it (if valid), since it's probably what the user expects. + // If invalid, the metadata contains the same alias, but in lowercase. + final LiteralCommandNode literal = command.getNode(); + final String primaryAlias = literal.getName(); + if (VelocityCommands.isValidAlias(primaryAlias)) { + // Register directly without copying + this.register(literal); + } + + for (final String alias : meta.getAliases()) { + if (primaryAlias.equals(alias)) { + continue; + } + this.register(literal, alias); + } + + // Brigadier commands don't support hinting, ignore + } + + @Override + public Class registrableSuperInterface() { + return BrigadierCommand.class; + } +} diff --git a/proxy/src/main/java/com/velocitypowered/proxy/command/registrar/CommandRegistrar.java b/proxy/src/main/java/com/velocitypowered/proxy/command/registrar/CommandRegistrar.java new file mode 100644 index 000000000..ea9200e0d --- /dev/null +++ b/proxy/src/main/java/com/velocitypowered/proxy/command/registrar/CommandRegistrar.java @@ -0,0 +1,33 @@ +package com.velocitypowered.proxy.command.registrar; + +import com.mojang.brigadier.tree.LiteralCommandNode; +import com.mojang.brigadier.tree.RootCommandNode; +import com.velocitypowered.api.command.Command; +import com.velocitypowered.api.command.CommandMeta; + +/** + * Creates and registers the {@link LiteralCommandNode} representations of + * a given {@link Command} in a {@link RootCommandNode}. + * + * @param the type of the command to register + */ +public interface CommandRegistrar { + + /** + * Registers the given command. + * + * @param command the command to register + * @param meta the command metadata, including the case-insensitive aliases + * @throws IllegalArgumentException if the given command cannot be registered + */ + void register(final T command, final CommandMeta meta); + + /** + * Returns the superclass or superinterface of all {@link Command} classes + * compatible with this registrar. Note that {@link #register(Command, CommandMeta)} + * may impose additional restrictions on individual {@link Command} instances. + * + * @return the superclass of all the classes compatible with this registrar + */ + Class registrableSuperInterface(); +} diff --git a/proxy/src/main/java/com/velocitypowered/proxy/command/registrar/InvocableCommandRegistrar.java b/proxy/src/main/java/com/velocitypowered/proxy/command/registrar/InvocableCommandRegistrar.java new file mode 100644 index 000000000..fd7ba0274 --- /dev/null +++ b/proxy/src/main/java/com/velocitypowered/proxy/command/registrar/InvocableCommandRegistrar.java @@ -0,0 +1,105 @@ +package com.velocitypowered.proxy.command.registrar; + +import com.google.common.base.Preconditions; +import com.mojang.brigadier.Command; +import com.mojang.brigadier.arguments.ArgumentType; +import com.mojang.brigadier.builder.LiteralArgumentBuilder; +import com.mojang.brigadier.context.CommandContextBuilder; +import com.mojang.brigadier.tree.ArgumentCommandNode; +import com.mojang.brigadier.tree.LiteralCommandNode; +import com.mojang.brigadier.tree.RootCommandNode; +import com.velocitypowered.api.command.CommandInvocation; +import com.velocitypowered.api.command.CommandMeta; +import com.velocitypowered.api.command.CommandSource; +import com.velocitypowered.api.command.InvocableCommand; +import com.velocitypowered.proxy.command.VelocityCommandMeta; +import com.velocitypowered.proxy.command.VelocityCommands; +import com.velocitypowered.proxy.command.brigadier.VelocityArgumentBuilder; +import com.velocitypowered.proxy.command.invocation.CommandInvocationFactory; +import java.util.Iterator; +import java.util.concurrent.locks.Lock; +import java.util.function.Predicate; + +/** + * Base class for {@link CommandRegistrar}s capable of registering a subinterface of + * {@link InvocableCommand} in a root node. + */ +abstract class InvocableCommandRegistrar, + I extends CommandInvocation, A> extends AbstractCommandRegistrar { + + private final CommandInvocationFactory invocationFactory; + private final ArgumentType argumentsType; + + protected InvocableCommandRegistrar(final RootCommandNode root, final Lock lock, + final CommandInvocationFactory invocationFactory, + final ArgumentType argumentsType) { + super(root, lock); + this.invocationFactory = Preconditions.checkNotNull(invocationFactory, "invocationFactory"); + this.argumentsType = Preconditions.checkNotNull(argumentsType, "argumentsType"); + } + + @Override + public void register(final T command, final CommandMeta meta) { + final Iterator aliases = meta.getAliases().iterator(); + + final String primaryAlias = aliases.next(); + final LiteralCommandNode literal = + this.createLiteral(command, meta, primaryAlias); + this.register(literal); + + while (aliases.hasNext()) { + final String alias = aliases.next(); + this.register(literal, alias); + } + } + + private LiteralCommandNode createLiteral(final T command, final CommandMeta meta, + final String alias) { + final Predicate> requirement = context -> { + final I invocation = invocationFactory.create(context); + return command.hasPermission(invocation); + }; + final Command callback = context -> { + final I invocation = invocationFactory.create(context); + command.execute(invocation); + return 1; // handled + }; + + final LiteralCommandNode literal = LiteralArgumentBuilder + .literal(alias) + .requiresWithContext((context, reader) -> { + if (reader.canRead()) { + // InvocableCommands do not follow a tree-like permissions checking structure. + // Thus, a CommandSource may be able to execute a command with arguments while + // not being able to execute the argument-less variant. + // Only check for permissions once parsing is complete. + return true; + } + return requirement.test(context); + }) + .executes(callback) + .build(); + + final ArgumentCommandNode arguments = VelocityArgumentBuilder + .velocityArgument(VelocityCommands.ARGS_NODE_NAME, argumentsType) + .requiresWithContext((context, reader) -> requirement.test(context)) + .executes(callback) + .suggests((context, builder) -> { + final I invocation = invocationFactory.create(context); + return command.suggestAsync(invocation).thenApply(suggestions -> { + for (String value : suggestions) { + Preconditions.checkNotNull(value, "suggestion"); + builder.suggest(value); + } + return builder.build(); + }); + }) + .build(); + literal.addChild(arguments); + + // Add hinting nodes + VelocityCommandMeta.copyHints(meta).forEach(literal::addChild); + + return literal; + } +} diff --git a/proxy/src/main/java/com/velocitypowered/proxy/command/registrar/RawCommandRegistrar.java b/proxy/src/main/java/com/velocitypowered/proxy/command/registrar/RawCommandRegistrar.java new file mode 100644 index 000000000..25e6acbdd --- /dev/null +++ b/proxy/src/main/java/com/velocitypowered/proxy/command/registrar/RawCommandRegistrar.java @@ -0,0 +1,24 @@ +package com.velocitypowered.proxy.command.registrar; + +import com.mojang.brigadier.arguments.StringArgumentType; +import com.mojang.brigadier.tree.RootCommandNode; +import com.velocitypowered.api.command.CommandSource; +import com.velocitypowered.api.command.RawCommand; +import com.velocitypowered.proxy.command.invocation.RawCommandInvocation; +import java.util.concurrent.locks.Lock; + +/** + * Registers {@link RawCommand}s in a root node. + */ +public final class RawCommandRegistrar + extends InvocableCommandRegistrar { + + public RawCommandRegistrar(final RootCommandNode root, final Lock lock) { + super(root, lock, RawCommandInvocation.FACTORY, StringArgumentType.greedyString()); + } + + @Override + public Class registrableSuperInterface() { + return RawCommand.class; + } +} diff --git a/proxy/src/main/java/com/velocitypowered/proxy/command/registrar/SimpleCommandRegistrar.java b/proxy/src/main/java/com/velocitypowered/proxy/command/registrar/SimpleCommandRegistrar.java new file mode 100644 index 000000000..b0b15857d --- /dev/null +++ b/proxy/src/main/java/com/velocitypowered/proxy/command/registrar/SimpleCommandRegistrar.java @@ -0,0 +1,24 @@ +package com.velocitypowered.proxy.command.registrar; + +import com.mojang.brigadier.tree.RootCommandNode; +import com.velocitypowered.api.command.CommandSource; +import com.velocitypowered.api.command.SimpleCommand; +import com.velocitypowered.proxy.command.brigadier.StringArrayArgumentType; +import com.velocitypowered.proxy.command.invocation.SimpleCommandInvocation; +import java.util.concurrent.locks.Lock; + +/** + * Registers {@link SimpleCommand}s in a root node. + */ +public final class SimpleCommandRegistrar + extends InvocableCommandRegistrar { + + public SimpleCommandRegistrar(final RootCommandNode root, final Lock lock) { + super(root, lock, SimpleCommandInvocation.FACTORY, StringArrayArgumentType.INSTANCE); + } + + @Override + public Class registrableSuperInterface() { + return SimpleCommand.class; + } +} diff --git a/proxy/src/main/java/com/velocitypowered/proxy/connection/backend/BackendPlaySessionHandler.java b/proxy/src/main/java/com/velocitypowered/proxy/connection/backend/BackendPlaySessionHandler.java index b3c7133cf..fcb44c7fc 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/connection/backend/BackendPlaySessionHandler.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/connection/backend/BackendPlaySessionHandler.java @@ -29,6 +29,7 @@ import com.velocitypowered.api.event.connection.PluginMessageEvent; import com.velocitypowered.api.network.ProtocolVersion; import com.velocitypowered.api.proxy.messages.ChannelIdentifier; import com.velocitypowered.proxy.VelocityServer; +import com.velocitypowered.proxy.command.CommandGraphInjector; import com.velocitypowered.proxy.connection.MinecraftConnection; import com.velocitypowered.proxy.connection.MinecraftSessionHandler; import com.velocitypowered.proxy.connection.client.ClientPlaySessionHandler; @@ -201,18 +202,8 @@ public class BackendPlaySessionHandler implements MinecraftSessionHandler { RootCommandNode rootNode = commands.getRootNode(); if (server.getConfiguration().isAnnounceProxyCommands()) { // Inject commands from the proxy. - RootCommandNode dispatcherRootNode = - (RootCommandNode) - filterNode(server.getCommandManager().getDispatcher().getRoot()); - assert dispatcherRootNode != null : "Filtering root node returned null."; - Collection> proxyNodes = dispatcherRootNode.getChildren(); - for (CommandNode node : proxyNodes) { - CommandNode existingServerChild = rootNode.getChild(node.getName()); - if (existingServerChild != null) { - rootNode.getChildren().remove(existingServerChild); - } - rootNode.addChild(node); - } + final CommandGraphInjector injector = server.getCommandManager().getInjector(); + injector.inject(rootNode, serverConn.getPlayer()); } server.getEventManager().fire( @@ -225,50 +216,6 @@ public class BackendPlaySessionHandler implements MinecraftSessionHandler { return true; } - /** - * Creates a deep copy of the provided command node, but removes any node that are not accessible - * by the player (respecting the requirement of the node). - * - * @param source source node - * @return filtered node - */ - private CommandNode filterNode(CommandNode source) { - CommandNode dest; - if (source instanceof RootCommandNode) { - dest = new RootCommandNode<>(); - } else { - if (source.getRequirement() != null) { - try { - if (!source.getRequirement().test(serverConn.getPlayer())) { - return null; - } - } catch (Throwable e) { - // swallow everything because plugins - logger.error( - "Requirement test for command node " + source + " encountered an exception", e); - } - } - - ArgumentBuilder destChildBuilder = source.createBuilder(); - destChildBuilder.requires((commandSource) -> true); - if (destChildBuilder.getRedirect() != null) { - destChildBuilder.redirect(filterNode(destChildBuilder.getRedirect())); - } - - dest = destChildBuilder.build(); - } - - for (CommandNode sourceChild : source.getChildren()) { - CommandNode destChild = filterNode(sourceChild); - if (destChild == null) { - continue; - } - dest.addChild(destChild); - } - - return dest; - } - @Override public void handleGeneric(MinecraftPacket packet) { if (packet instanceof PluginMessage) { diff --git a/proxy/src/main/java/com/velocitypowered/proxy/util/BrigadierUtils.java b/proxy/src/main/java/com/velocitypowered/proxy/util/BrigadierUtils.java deleted file mode 100644 index db6a5919d..000000000 --- a/proxy/src/main/java/com/velocitypowered/proxy/util/BrigadierUtils.java +++ /dev/null @@ -1,169 +0,0 @@ -/* - * Copyright (C) 2018 Velocity Contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package com.velocitypowered.proxy.util; - -import com.google.common.base.Preconditions; -import com.google.common.base.Splitter; -import com.mojang.brigadier.Command; -import com.mojang.brigadier.arguments.StringArgumentType; -import com.mojang.brigadier.builder.ArgumentBuilder; -import com.mojang.brigadier.builder.LiteralArgumentBuilder; -import com.mojang.brigadier.builder.RequiredArgumentBuilder; -import com.mojang.brigadier.context.CommandContext; -import com.mojang.brigadier.suggestion.SuggestionProvider; -import com.mojang.brigadier.tree.CommandNode; -import com.mojang.brigadier.tree.LiteralCommandNode; -import com.velocitypowered.api.command.CommandSource; -import java.util.Locale; -import org.checkerframework.checker.nullness.qual.Nullable; - -/** - * Provides utilities for working with Brigadier commands. - */ -public final class BrigadierUtils { - - private static final Splitter SPACE_SPLITTER = Splitter.on(' '); - - /** - * Returns a literal node that redirects its execution to - * the given destination node. - * - * @param alias the command alias - * @param destination the destination node - * @return the built node - */ - public static LiteralCommandNode buildRedirect( - final String alias, final LiteralCommandNode destination) { - // Redirects only work for nodes with children, but break the top argument-less command. - // Manually adding the root command after setting the redirect doesn't fix it. - // See https://github.com/Mojang/brigadier/issues/46). Manually clone the node instead. - LiteralArgumentBuilder builder = LiteralArgumentBuilder - .literal(alias.toLowerCase(Locale.ENGLISH)) - .requires(destination.getRequirement()) - .forward( - destination.getRedirect(), destination.getRedirectModifier(), destination.isFork()) - .executes(destination.getCommand()); - for (CommandNode child : destination.getChildren()) { - builder.then(child); - } - return builder.build(); - } - - /** - * Returns a literal node that optionally accepts arguments - * as a raw {@link String}. - * - * @param alias the literal alias - * @param brigadierCommand the command to execute - * @param suggestionProvider the suggestion provider - * @return the built node - */ - public static LiteralCommandNode buildRawArgumentsLiteral( - final String alias, final Command brigadierCommand, - SuggestionProvider suggestionProvider) { - return LiteralArgumentBuilder - .literal(alias.toLowerCase(Locale.ENGLISH)) - .then(RequiredArgumentBuilder - .argument("arguments", StringArgumentType.greedyString()) - .suggests(suggestionProvider) - .executes(brigadierCommand)) - .executes(brigadierCommand) - .build(); - } - - /** - * Returns the used command alias. - * - * @param context the command context - * @return the parsed command alias - */ - public static String getAlias(final CommandContext context) { - return context.getNodes().get(0).getNode().getName(); - } - - /** - * Returns the raw {@link String} arguments of a command execution. - * - * @param context the command context - * @return the parsed arguments - */ - public static String getRawArguments(final CommandContext context) { - String cmdLine = context.getInput(); - int firstSpace = cmdLine.indexOf(' '); - if (firstSpace == -1) { - return ""; - } - return cmdLine.substring(firstSpace + 1); - } - - /** - * Returns the splitted arguments of a command node built with - * {@link #buildRawArgumentsLiteral(String, Command, SuggestionProvider)}. - * - * @param context the command context - * @return the parsed arguments - */ - public static String[] getSplitArguments(final CommandContext context) { - String line = getRawArguments(context); - if (line.isEmpty()) { - return new String[0]; - } - return SPACE_SPLITTER.splitToList(line).toArray(new String[0]); - } - - /** - * Returns the normalized representation of the given command input. - * - * @param cmdLine the command input - * @param trim whether to trim argument-less inputs - * @return the normalized command - */ - public static String normalizeInput(final String cmdLine, final boolean trim) { - // Command aliases are case insensitive, but Brigadier isn't - String command = trim ? cmdLine.trim() : cmdLine; - int firstSpace = command.indexOf(' '); - if (firstSpace != -1) { - return command.substring(0, firstSpace).toLowerCase(Locale.ENGLISH) - + command.substring(firstSpace); - } - return command.toLowerCase(Locale.ENGLISH); - } - - /** - * Prepares the given command node prior for hinting metadata to - * a {@link com.velocitypowered.api.command.Command}. - * - * @param node the command node to be wrapped - * @param command the command to execute - * @return the wrapped command node - */ - public static CommandNode wrapForHinting( - final CommandNode node, final @Nullable Command command) { - Preconditions.checkNotNull(node, "node"); - ArgumentBuilder builder = node.createBuilder(); - builder.executes(command); - for (CommandNode child : node.getChildren()) { - builder.then(wrapForHinting(child, command)); - } - return builder.build(); - } - - private BrigadierUtils() { - throw new AssertionError(); - } -} diff --git a/proxy/src/test/java/com/velocitypowered/proxy/command/CommandGraphInjectorTests.java b/proxy/src/test/java/com/velocitypowered/proxy/command/CommandGraphInjectorTests.java new file mode 100644 index 000000000..e843cf541 --- /dev/null +++ b/proxy/src/test/java/com/velocitypowered/proxy/command/CommandGraphInjectorTests.java @@ -0,0 +1,53 @@ +package com.velocitypowered.proxy.command; + +import com.mojang.brigadier.CommandDispatcher; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +public class CommandGraphInjectorTests { + + private CommandDispatcher dispatcher; + private Lock lock; + private CommandGraphInjector injector; + + @BeforeEach + void setUp() { + this.dispatcher = new CommandDispatcher<>(); + this.lock = new ReentrantLock(); + this.injector = new CommandGraphInjector<>(this.dispatcher, this.lock); + } + + // TODO + + @Test + void testInjectInvocableCommand() { + // Preserves arguments node and hints + } + + @Test + void testFiltersImpermissibleAlias() { + + } + + @Test + void testInjectsBrigadierCommand() { + + } + + @Test + void testFiltersImpermissibleBrigadierCommandChildren() { + + } + + @Test + void testInjectFiltersBrigadierCommandRedirects() { + + } + + @Test + void testInjectOverridesAliasInDestination() { + + } +} diff --git a/proxy/src/test/java/com/velocitypowered/proxy/command/SuggestionsProviderTests.java b/proxy/src/test/java/com/velocitypowered/proxy/command/SuggestionsProviderTests.java new file mode 100644 index 000000000..b3d37c24e --- /dev/null +++ b/proxy/src/test/java/com/velocitypowered/proxy/command/SuggestionsProviderTests.java @@ -0,0 +1,5 @@ +package com.velocitypowered.proxy.command; + +public class SuggestionsProviderTests { + // TODO +} diff --git a/proxy/src/test/java/com/velocitypowered/proxy/command/brigadier/StringArrayArgumentTypeTests.java b/proxy/src/test/java/com/velocitypowered/proxy/command/brigadier/StringArrayArgumentTypeTests.java new file mode 100644 index 000000000..64769b1ad --- /dev/null +++ b/proxy/src/test/java/com/velocitypowered/proxy/command/brigadier/StringArrayArgumentTypeTests.java @@ -0,0 +1,94 @@ +package com.velocitypowered.proxy.command.brigadier; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; + +import com.mojang.brigadier.StringReader; +import com.mojang.brigadier.exceptions.CommandSyntaxException; +import org.junit.jupiter.api.Test; + +public class StringArrayArgumentTypeTests { + + private static final StringArrayArgumentType TYPE = StringArrayArgumentType.INSTANCE; + + @Test + void testEmptyString() throws CommandSyntaxException { + final StringReader reader = new StringReader(""); + assertArrayEquals(new String[0], TYPE.parse(reader)); + } + + @Test + void testParseWord() throws CommandSyntaxException { + final StringReader reader = new StringReader("Hello"); + assertArrayEquals(new String[] { "Hello" }, TYPE.parse(reader)); + assertFalse(reader.canRead()); + } + + @Test + void testParseString() throws CommandSyntaxException { + final StringReader reader = new StringReader("Hello world!"); + assertArrayEquals(new String[] { "Hello", "world!" }, TYPE.parse(reader)); + assertFalse(reader.canRead()); + } + + @Test + void testNoEscaping() throws CommandSyntaxException { + final StringReader reader = new StringReader("\"My house\" is blue"); + assertArrayEquals(new String[] { "\"My", "house\"", "is", "blue" }, TYPE.parse(reader)); + assertFalse(reader.canRead()); + } + + @Test + void testUnbalancedEscapingIsIgnored() throws CommandSyntaxException { + final StringReader reader = new StringReader("This is a \"sentence"); + assertArrayEquals(new String[] { "This", "is", "a", "\"sentence" }, TYPE.parse(reader)); + assertFalse(reader.canRead()); + } + + @Test + void testLeadingWhitespace() throws CommandSyntaxException { + final StringReader reader = new StringReader(" ¡Hola!"); + assertArrayEquals(new String[] { "", "¡Hola!" }, TYPE.parse(reader)); + assertFalse(reader.canRead()); + } + + @Test + void testMultipleLeadingWhitespace() throws CommandSyntaxException { + final StringReader reader = new StringReader(" Anguish Languish"); + assertArrayEquals(new String[] { "", "", "", "Anguish", "Languish" }, TYPE.parse(reader)); + assertFalse(reader.canRead()); + } + + @Test + void testTrailingWhitespace() throws CommandSyntaxException { + final StringReader reader = new StringReader("This is a test. "); + assertArrayEquals(new String[] { "This", "is", "a", "test.", "" }, TYPE.parse(reader)); + assertFalse(reader.canRead()); + } + + @Test + void testMultipleTrailingWhitespace() throws CommandSyntaxException { + final StringReader reader = new StringReader("Lorem ipsum "); + assertArrayEquals(new String[] { "Lorem", "ipsum", "", "" }, TYPE.parse(reader)); + assertFalse(reader.canRead()); + } + + @Test + void testMultipleWhitespaceCharsArePreserved() throws CommandSyntaxException { + final StringReader reader = new StringReader( + " This is a message that shouldn't be normalized "); + assertArrayEquals(new String[] { + "", "This", "", "is", "a", "", "", "message", "", "that", "shouldn't", "", "", "", "be", + "normalized", "", ""}, TYPE.parse(reader)); + assertFalse(reader.canRead()); + } + + @Test + void testRespectsCursor() throws CommandSyntaxException { + final StringReader reader = new StringReader("Hello beautiful world"); + reader.setCursor(6); + + assertArrayEquals(new String[] { "beautiful", "world"}, TYPE.parse(reader)); + assertFalse(reader.canRead()); + } +} diff --git a/proxy/src/test/java/com/velocitypowered/proxy/command/brigadier/VelocityArgumentCommandNodeTests.java b/proxy/src/test/java/com/velocitypowered/proxy/command/brigadier/VelocityArgumentCommandNodeTests.java new file mode 100644 index 000000000..dfbf20374 --- /dev/null +++ b/proxy/src/test/java/com/velocitypowered/proxy/command/brigadier/VelocityArgumentCommandNodeTests.java @@ -0,0 +1,81 @@ +package com.velocitypowered.proxy.command.brigadier; + +import static com.velocitypowered.proxy.command.brigadier.VelocityArgumentBuilder.velocityArgument; +import static org.junit.jupiter.api.Assertions.*; + +import com.mojang.brigadier.CommandDispatcher; +import com.mojang.brigadier.StringReader; +import com.mojang.brigadier.context.CommandContextBuilder; +import com.mojang.brigadier.context.ParsedArgument; +import com.mojang.brigadier.context.StringRange; +import com.mojang.brigadier.exceptions.CommandSyntaxException; +import com.mojang.brigadier.suggestion.Suggestions; +import com.mojang.brigadier.suggestion.SuggestionsBuilder; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +@SuppressWarnings("unchecked") +public class VelocityArgumentCommandNodeTests { + + private static final StringArrayArgumentType STRING_ARRAY = StringArrayArgumentType.INSTANCE; + + private CommandContextBuilder contextBuilder; + + @BeforeEach + void setUp() { + final CommandDispatcher dispatcher = new CommandDispatcher<>(); + this.contextBuilder = new CommandContextBuilder<>(dispatcher, new Object(), + dispatcher.getRoot(), 0); + } + + @Test + void testParse() throws CommandSyntaxException { + final VelocityArgumentCommandNode node = + velocityArgument("foo", STRING_ARRAY).build(); + final StringReader reader = new StringReader("hello world"); + node.parse(reader, this.contextBuilder); + + final StringRange expectedRange = StringRange.between(0, reader.getTotalLength()); + + assertFalse(reader.canRead()); + + assertFalse(this.contextBuilder.getNodes().isEmpty()); + assertSame(node, this.contextBuilder.getNodes().get(0).getNode()); + assertEquals(expectedRange, this.contextBuilder.getNodes().get(0).getRange()); + + assertTrue(this.contextBuilder.getArguments().containsKey("foo")); + final ParsedArgument parsed = + (ParsedArgument) this.contextBuilder.getArguments().get("foo"); + assertArrayEquals(new String[] { "hello", "world" }, parsed.getResult()); + assertEquals(expectedRange, parsed.getRange()); + } + + @Test + void testDefaultSuggestions() throws CommandSyntaxException { + final VelocityArgumentCommandNode node = + velocityArgument("foo", STRING_ARRAY).build(); + final Suggestions result = node.listSuggestions( + this.contextBuilder.build(""), new SuggestionsBuilder("", 0)).join(); + + assertTrue(result.isEmpty()); + } + + // This only tests delegation to the given SuggestionsProvider; suggestions merging + // and filtering is already tested in Brigadier. + @Test + void testCustomSuggestions() throws CommandSyntaxException { + final VelocityArgumentCommandNode node = + velocityArgument("foo", STRING_ARRAY) + .suggests((context, builder) -> { + builder.suggest("bar"); + builder.suggest("baz"); + return builder.buildFuture(); + }) + .build(); + final Suggestions result = node.listSuggestions( + this.contextBuilder.build(""), new SuggestionsBuilder("", 0)).join(); + + assertEquals("bar", result.getList().get(0).getText()); + assertEquals("baz", result.getList().get(1).getText()); + } +}