feat(core): support root command deletion & standardize capabilities (#369)

This commit is contained in:
Alexander Söderberg 2022-06-08 13:23:41 +02:00 committed by Jason
parent 08a97b2c4f
commit 28ff5d3003
14 changed files with 416 additions and 16 deletions

View file

@ -0,0 +1,85 @@
//
// MIT License
//
// Copyright (c) 2021 Alexander Söderberg & Contributors
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
//
package cloud.commandframework;
import org.apiguardian.api.API;
import org.checkerframework.checker.nullness.qual.NonNull;
/**
* Represents a capability that a cloud implementation may have.
*
* @since 1.7.0
*/
@API(status = API.Status.STABLE, since = "1.7.0")
public interface CloudCapability {
/**
* Returns the friendly name of this capability.
*
* @return the name of the capability
*/
@Override @NonNull String toString();
/**
* Standard {@link CloudCapability capabilities}.
*
* @since 1.7.0
*/
@API(status = API.Status.STABLE, since = "1.7.0")
enum StandardCapabilities implements CloudCapability {
/**
* The capability to delete root commands using {@link CommandManager#deleteRootCommand(String)}.
*/
ROOT_COMMAND_DELETION;
@Override
public @NonNull String toString() {
return name();
}
}
/**
* Exception thrown when a {@link CloudCapability} is missing, when a method that requires the presence of that
* capability is invoked.
*
* @since 1.7.0
*/
@API(status = API.Status.STABLE, since = "1.7.0")
@SuppressWarnings("serial")
final class CloudCapabilityMissingException extends RuntimeException {
private static final long serialVersionUID = 8961652857372971486L;
/**
* Create a new cloud capability missing exception instance.
*
* @param capability the missing capability
*/
public CloudCapabilityMissingException(final @NonNull CloudCapability capability) {
super(String.format("Missing capability '%s'", capability));
}
}
}

View file

@ -29,6 +29,7 @@ import cloud.commandframework.arguments.CommandSuggestionEngine;
import cloud.commandframework.arguments.CommandSyntaxFormatter;
import cloud.commandframework.arguments.DelegatingCommandSuggestionEngineFactory;
import cloud.commandframework.arguments.StandardCommandSyntaxFormatter;
import cloud.commandframework.arguments.StaticArgument;
import cloud.commandframework.arguments.flags.CommandFlag;
import cloud.commandframework.arguments.parser.ArgumentParser;
import cloud.commandframework.arguments.parser.ParserParameter;
@ -66,15 +67,18 @@ import java.util.Collection;
import java.util.Collections;
import java.util.EnumSet;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.BiConsumer;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.stream.Collectors;
import org.apiguardian.api.API;
import org.checkerframework.checker.nullness.qual.NonNull;
import org.checkerframework.checker.nullness.qual.Nullable;
@ -100,6 +104,7 @@ public abstract class CommandManager<C> {
private final CommandExecutionCoordinator<C> commandExecutionCoordinator;
private final CommandTree<C> commandTree;
private final CommandSuggestionEngine<C> commandSuggestionEngine;
private final Set<CloudCapability> capabilities = new HashSet<>();
private CaptionVariableReplacementHandler captionVariableReplacementHandler = new SimpleCaptionVariableReplacementHandler();
private CommandSyntaxFormatter<C> commandSyntaxFormatter = new StandardCommandSyntaxFormatter<>();
@ -297,6 +302,40 @@ public abstract class CommandManager<C> {
return this.commandRegistrationHandler;
}
/**
* Registers the given {@code capability}.
*
* @param capability the capability
* @since 1.7.0
*/
@API(status = API.Status.STABLE, since = "1.7.0")
protected final void registerCapability(final @NonNull CloudCapability capability) {
this.capabilities.add(capability);
}
/**
* Checks whether the cloud implementation has the given {@code capability}.
*
* @param capability the capability
* @return {@code true} if the implementation has the {@code capability}, {@code false} if not
* @since 1.7.0
*/
@API(status = API.Status.STABLE, since = "1.7.0")
public boolean hasCapability(final @NonNull CloudCapability capability) {
return this.capabilities.contains(capability);
}
/**
* Returns an unmodifiable snapshot of the currently registered {@link CloudCapability capabilities}.
*
* @return the currently registered capabilities
* @since 1.7.0
*/
@API(status = API.Status.STABLE, since = "1.7.0")
public @NonNull Collection<@NonNull CloudCapability> capabilities() {
return Collections.unmodifiableSet(new HashSet<>(this.capabilities));
}
protected final void setCommandRegistrationHandler(final @NonNull CommandRegistrationHandler commandRegistrationHandler) {
this.requireState(RegistrationState.BEFORE_REGISTRATION);
this.commandRegistrationHandler = commandRegistrationHandler;
@ -382,6 +421,55 @@ public abstract class CommandManager<C> {
*/
public abstract boolean hasPermission(@NonNull C sender, @NonNull String permission);
/**
* Deletes the given {@code rootCommand}.
* <p>
* This will delete all chains that originate at the root command.
*
* @param rootCommand The root command to delete
* @throws CloudCapability.CloudCapabilityMissingException If {@link CloudCapability.StandardCapabilities#ROOT_COMMAND_DELETION} is missing
* @since 1.7.0
*/
@API(status = API.Status.EXPERIMENTAL, since = "1.7.0")
public void deleteRootCommand(final @NonNull String rootCommand) throws CloudCapability.CloudCapabilityMissingException {
if (!this.hasCapability(CloudCapability.StandardCapabilities.ROOT_COMMAND_DELETION)) {
throw new CloudCapability.CloudCapabilityMissingException(CloudCapability.StandardCapabilities.ROOT_COMMAND_DELETION);
}
// Mark the command for deletion.
final CommandTree.Node<@Nullable CommandArgument<C, ?>> node = this.commandTree.getNamedNode(rootCommand);
if (node == null) {
throw new IllegalArgumentException(String.format("No root command named '%s' exists", rootCommand));
}
// The registration handler gets to act before we destruct the command.
this.commandRegistrationHandler.unregisterRootCommand((StaticArgument<?>) node.getValue());
// We then delete it from the tree.
this.commandTree.deleteRecursively(node);
// And lastly we re-build the entire tree.
this.commandTree.verifyAndRegister();
}
/**
* Returns all root command names.
*
* @return Root command names.
* @since 1.7.0
*/
@SuppressWarnings("unchecked")
@API(status = API.Status.STABLE, since = "1.7.0")
public @NonNull Collection<@NonNull String> rootCommands() {
return this.commandTree.getRootNodes()
.stream()
.map(CommandTree.Node::getValue)
.filter(arg -> arg instanceof StaticArgument)
.map(arg -> (StaticArgument<C>) arg)
.map(StaticArgument::getName)
.collect(Collectors.toList());
}
/**
* Create a new command builder. This will also register the creating manager in the command
* builder using {@link Command.Builder#manager(CommandManager)}, so that the command

View file

@ -953,6 +953,29 @@ public final class CommandTree<C> {
return null;
}
void deleteRecursively(final @NonNull Node<@Nullable CommandArgument<C, ?>> node) {
for (final Node<@Nullable CommandArgument<C, ?>> child : new ArrayList<>(node.children)) {
this.deleteRecursively(child);
}
// We need to remove it from the tree.
this.removeNode(node);
}
private boolean removeNode(final @NonNull Node<@Nullable CommandArgument<C, ?>> node) {
if (node.isLeaf()) {
if (this.getRootNodes().contains(node)) {
this.internalTree.removeChild(node);
} else {
return node.getParent().removeChild(node);
}
} else {
throw new IllegalStateException(String.format("Cannot delete intermediate node '%s'", node));
}
return false;
}
/**
* Get the command manager
*
@ -971,7 +994,7 @@ public final class CommandTree<C> {
private final Map<String, Object> nodeMeta = new HashMap<>();
private final List<Node<T>> children = new LinkedList<>();
private final T value;
private T value;
private Node<T> parent;
private Node(final @Nullable T value) {
@ -1002,6 +1025,10 @@ public final class CommandTree<C> {
return null;
}
private boolean removeChild(final @NonNull Node<T> child) {
return this.children.remove(child);
}
/**
* Check if the node is a leaf node
*

View file

@ -24,6 +24,7 @@
package cloud.commandframework.internal;
import cloud.commandframework.Command;
import cloud.commandframework.arguments.StaticArgument;
import org.apiguardian.api.API;
import org.checkerframework.checker.nullness.qual.NonNull;
@ -54,6 +55,15 @@ public interface CommandRegistrationHandler {
*/
boolean registerCommand(@NonNull Command<?> command);
/**
* Requests that the given {@code rootCommand} should be unregistered.
*
* @param rootCommand The command to delete
* @since 1.7.0
*/
default void unregisterRootCommand(final @NonNull StaticArgument<?> rootCommand) {
}
@API(status = API.Status.INTERNAL, consumers = "cloud.commandframework.*")
final class NullCommandRegistrationHandler implements CommandRegistrationHandler {
@ -66,6 +76,9 @@ public interface CommandRegistrationHandler {
return true;
}
@Override
public void unregisterRootCommand(final @NonNull StaticArgument<?> rootCommand) {
}
}
}

View file

@ -0,0 +1,149 @@
//
// MIT License
//
// Copyright (c) 2021 Alexander Söderberg & Contributors
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
//
package cloud.commandframework;
import cloud.commandframework.arguments.standard.StringArgument;
import cloud.commandframework.exceptions.NoSuchCommandException;
import cloud.commandframework.execution.CommandExecutionCoordinator;
import cloud.commandframework.execution.CommandExecutionHandler;
import cloud.commandframework.internal.CommandRegistrationHandler;
import cloud.commandframework.meta.CommandMeta;
import cloud.commandframework.meta.SimpleCommandMeta;
import java.util.concurrent.CompletionException;
import org.checkerframework.checker.nullness.qual.NonNull;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static com.google.common.truth.Truth.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verifyNoMoreInteractions;
class CommandDeletionTest {
private CommandManager<TestCommandSender> commandManager;
@BeforeEach
void setup() {
this.commandManager = new CommandManager<TestCommandSender>(
CommandExecutionCoordinator.simpleCoordinator(),
CommandRegistrationHandler.nullCommandRegistrationHandler()
) {
{
this.registerCapability(CloudCapability.StandardCapabilities.ROOT_COMMAND_DELETION);
}
@Override
public boolean hasPermission(
final @NonNull TestCommandSender sender,
final @NonNull String permission
) {
return true;
}
@Override
public @NonNull CommandMeta createDefaultCommandMeta() {
return SimpleCommandMeta.empty();
}
};
}
@Test
void deleteSimpleCommand() {
// Arrange
this.commandManager.command(this.commandManager.commandBuilder("test").build());
// Pre-assert.
this.commandManager.executeCommand(new TestCommandSender(), "test").join();
// Act
this.commandManager.deleteRootCommand("test");
// Assert
final CompletionException completionException = assertThrows(
CompletionException.class,
() -> this.commandManager.executeCommand(new TestCommandSender(), "test").join()
);
assertThat(completionException).hasCauseThat().isInstanceOf(NoSuchCommandException.class);
assertThat(this.commandManager.suggest(new TestCommandSender(), "")).isEmpty();
assertThat(this.commandManager.getCommandTree().getRootNodes()).isEmpty();
}
@Test
@SuppressWarnings("unchecked")
void deleteIntermediateCommand() {
// Arrange
final CommandExecutionHandler<TestCommandSender> handler1 = mock(CommandExecutionHandler.class);
final Command<TestCommandSender> command1 = this.commandManager
.commandBuilder("test")
.handler(handler1)
.build();
this.commandManager.command(command1);
final CommandExecutionHandler<TestCommandSender> handler2 = mock(CommandExecutionHandler.class);
final Command<TestCommandSender> command2 = this.commandManager
.commandBuilder("test")
.literal("literal")
.handler(handler2)
.build();
this.commandManager.command(command2);
final CommandExecutionHandler<TestCommandSender> handler3 = mock(CommandExecutionHandler.class);
final Command<TestCommandSender> command3 = this.commandManager
.commandBuilder("test")
.literal("literal")
.argument(StringArgument.of("string"))
.handler(handler3)
.build();
this.commandManager.command(command3);
// Act
this.commandManager.deleteRootCommand("test");
// Assert
final CompletionException completionException = assertThrows(
CompletionException.class,
() -> this.commandManager.executeCommand(new TestCommandSender(), "test").join()
);
assertThat(completionException).hasCauseThat().isInstanceOf(NoSuchCommandException.class);
final CompletionException completionException2 = assertThrows(
CompletionException.class,
() -> this.commandManager.executeCommand(new TestCommandSender(), "test literal").join()
);
assertThat(completionException2).hasCauseThat().isInstanceOf(NoSuchCommandException.class);
final CompletionException completionException3 = assertThrows(
CompletionException.class,
() -> this.commandManager.executeCommand(new TestCommandSender(), "test literal hm").join()
);
assertThat(completionException3).hasCauseThat().isInstanceOf(NoSuchCommandException.class);
verifyNoMoreInteractions(handler1);
verifyNoMoreInteractions(handler2);
verifyNoMoreInteractions(handler3);
assertThat(this.commandManager.getCommandTree().getRootNodes()).isEmpty();
}
}