9.6.1
October 2024
diff --git a/commandapi-annotations/pom.xml b/commandapi-annotations/pom.xml
index abaf2e3e8f..2dea8cc63e 100644
--- a/commandapi-annotations/pom.xml
+++ b/commandapi-annotations/pom.xml
@@ -18,7 +18,7 @@
dev.jorel
commandapi
- 9.6.1
+ 9.7.0
commandapi-annotations
diff --git a/commandapi-codecov/pom.xml b/commandapi-codecov/pom.xml
index 7bfc881d1c..df598cb551 100644
--- a/commandapi-codecov/pom.xml
+++ b/commandapi-codecov/pom.xml
@@ -20,7 +20,7 @@
dev.jorel
commandapi
- 9.6.1
+ 9.7.0
commandapi-codecov
@@ -58,6 +58,12 @@
${project.version}
test
+
+ dev.jorel
+ commandapi-bukkit-test-toolkit
+ ${project.version}
+
+
diff --git a/commandapi-core/pom.xml b/commandapi-core/pom.xml
index 81a048d5f1..d72071c394 100644
--- a/commandapi-core/pom.xml
+++ b/commandapi-core/pom.xml
@@ -19,7 +19,7 @@
commandapi
dev.jorel
- 9.6.1
+ 9.7.0
4.0.0
diff --git a/commandapi-core/src/main/java/dev/jorel/commandapi/AbstractArgumentTree.java b/commandapi-core/src/main/java/dev/jorel/commandapi/AbstractArgumentTree.java
index 6024256e0e..c591e57f5d 100644
--- a/commandapi-core/src/main/java/dev/jorel/commandapi/AbstractArgumentTree.java
+++ b/commandapi-core/src/main/java/dev/jorel/commandapi/AbstractArgumentTree.java
@@ -3,6 +3,7 @@
import dev.jorel.commandapi.arguments.AbstractArgument;
import java.util.ArrayList;
+import java.util.Arrays;
import java.util.List;
/**
@@ -60,6 +61,41 @@ public Impl then(final AbstractArgumentTree, Argument, CommandSender> tree) {
return instance();
}
+ /**
+ * Creates a chain of child branches starting at this node
+ *
+ * {@code thenNested(a, b, c)} is equivalent to {@link #then}{@code (a.then(b.then(c)))}.
+ *
+ * @param trees The child branches to add in a chain.
+ * @return this tree node
+ */
+ public final Impl thenNested(List> trees) {
+ int length = trees.size();
+ if (length == 0) {
+ return instance();
+ }
+
+ AbstractArgumentTree, Argument, CommandSender> combined = trees.get(length - 1);
+ for (int i = length - 2; i >= 0; i--) {
+ combined = trees.get(i).then(combined);
+ }
+
+ return then(combined);
+ }
+
+ /**
+ * Creates a chain of child branches starting at this node
+ *
+ * {@code thenNested(a, b, c)} is equivalent to {@link #then}{@code (a.then(b.then(c)))}.
+ *
+ * @param trees The child branches to add in a chain.
+ * @return this tree node
+ */
+ @SafeVarargs
+ public final Impl thenNested(final AbstractArgumentTree, Argument, CommandSender>... trees) {
+ return thenNested(Arrays.asList(trees));
+ }
+
List> getExecutions() {
List> executions = new ArrayList<>();
// If this is executable, add its execution
diff --git a/commandapi-core/src/main/java/dev/jorel/commandapi/AbstractCommandTree.java b/commandapi-core/src/main/java/dev/jorel/commandapi/AbstractCommandTree.java
index b8d010759b..cb8288abbf 100644
--- a/commandapi-core/src/main/java/dev/jorel/commandapi/AbstractCommandTree.java
+++ b/commandapi-core/src/main/java/dev/jorel/commandapi/AbstractCommandTree.java
@@ -3,6 +3,7 @@
import dev.jorel.commandapi.arguments.AbstractArgument;
import java.util.ArrayList;
+import java.util.Arrays;
import java.util.List;
/**
@@ -43,6 +44,41 @@ public Impl then(final AbstractArgumentTree, Argument, CommandSender> tree) {
return instance();
}
+ /**
+ * Creates a chain of child branches starting at this node
+ *
+ * {@code thenNested(a, b, c)} is equivalent to {@link #then}{@code (a.then(b.then(c)))}.
+ *
+ * @param trees The child branches to add in a chain.
+ * @return this tree node
+ */
+ public final Impl thenNested(List> trees) {
+ int length = trees.size();
+ if (length == 0) {
+ return instance();
+ }
+
+ AbstractArgumentTree, Argument, CommandSender> combined = trees.get(length - 1);
+ for (int i = length - 2; i >= 0; i--) {
+ combined = trees.get(i).then(combined);
+ }
+
+ return then(combined);
+ }
+
+ /**
+ * Creates a chain of child branches starting at this node
+ *
+ * {@code thenNested(a, b, c)} is equivalent to {@link #then}{@code (a.then(b.then(c)))}.
+ *
+ * @param trees The child branches to add in a chain.
+ * @return this tree node
+ */
+ @SafeVarargs
+ public final Impl thenNested(final AbstractArgumentTree, Argument, CommandSender>... trees) {
+ return thenNested(Arrays.asList(trees));
+ }
+
/**
* Registers the command with a given namespace
*
diff --git a/commandapi-core/src/main/java/dev/jorel/commandapi/CommandAPIHandler.java b/commandapi-core/src/main/java/dev/jorel/commandapi/CommandAPIHandler.java
index c8f34841c8..08ce1a7342 100644
--- a/commandapi-core/src/main/java/dev/jorel/commandapi/CommandAPIHandler.java
+++ b/commandapi-core/src/main/java/dev/jorel/commandapi/CommandAPIHandler.java
@@ -185,9 +185,8 @@ public CommandAPIPlatform getPlatform() {
* @param executor code to be ran when the command is executed
* @param converted True if this command is being converted from another plugin, and false otherwise
* @return a brigadier command which is registered internally
- * @throws CommandSyntaxException if an error occurs when the command is ran
*/
- Command generateCommand(Argument[] args, CommandAPIExecutor> executor, boolean converted) {
+ public Command generateCommand(Argument[] args, CommandAPIExecutor> executor, boolean converted) {
// Generate our command from executor
return cmdCtx -> {
diff --git a/commandapi-core/src/main/java/dev/jorel/commandapi/CommandAPIPlatform.java b/commandapi-core/src/main/java/dev/jorel/commandapi/CommandAPIPlatform.java
index 981e97d654..f215e37cec 100644
--- a/commandapi-core/src/main/java/dev/jorel/commandapi/CommandAPIPlatform.java
+++ b/commandapi-core/src/main/java/dev/jorel/commandapi/CommandAPIPlatform.java
@@ -136,7 +136,8 @@ public interface CommandAPIPlatform dispatcher) throws IOException;
/**
- * @return A new default Logger meant for the CommandAPI to use
+ * @return A new default Logger meant for the CommandAPI to use. This logger will be used by the CommandAPI
+ * if one is not set using {@link CommandAPI#setLogger(CommandAPILogger)}.
*/
public default CommandAPILogger getLogger() {
return new CommandAPILogger() {
diff --git a/commandapi-core/src/main/java/dev/jorel/commandapi/CommandAPIVersionHandler.java b/commandapi-core/src/main/java/dev/jorel/commandapi/CommandAPIVersionHandler.java
index 9ccc599192..a28b569b12 100644
--- a/commandapi-core/src/main/java/dev/jorel/commandapi/CommandAPIVersionHandler.java
+++ b/commandapi-core/src/main/java/dev/jorel/commandapi/CommandAPIVersionHandler.java
@@ -5,7 +5,7 @@
* file within the commandapi-core module is NOT used at run time. Instead, the platform modules
* replace this class with their own version that handles loads the correct class for their version.
*/
-public interface CommandAPIVersionHandler {
+public abstract class CommandAPIVersionHandler {
/**
* Returns an instance of the version's implementation of CommandAPIPlatform.
diff --git a/commandapi-documentation-code/pom.xml b/commandapi-documentation-code/pom.xml
index 25f3776a02..441733d730 100644
--- a/commandapi-documentation-code/pom.xml
+++ b/commandapi-documentation-code/pom.xml
@@ -7,7 +7,7 @@
commandapi
dev.jorel
- 9.6.1
+ 9.7.0
commandapi-documentation-code
@@ -48,7 +48,6 @@
net.kyori
adventure-platform-bukkit
4.2.0
- test
io.papermc.paper
@@ -57,6 +56,25 @@
provided
+
+
+ com.github.seeseemelk
+ MockBukkit-v1.20
+ 3.9.0
+
+
+
+ dev.jorel
+ commandapi-bukkit-test-toolkit
+ ${project.version}
+
+
+ org.junit.jupiter
+ junit-jupiter-engine
+ 5.8.2
+
+
dev.jorel
diff --git a/commandapi-documentation-code/src/main/java/dev/jorel/commandapi/examples/java/Examples.java b/commandapi-documentation-code/src/main/java/dev/jorel/commandapi/examples/java/Examples.java
index 9fb61a2228..dd5cb39347 100644
--- a/commandapi-documentation-code/src/main/java/dev/jorel/commandapi/examples/java/Examples.java
+++ b/commandapi-documentation-code/src/main/java/dev/jorel/commandapi/examples/java/Examples.java
@@ -20,6 +20,8 @@
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*******************************************************************************/
+import be.seeseemelk.mockbukkit.MockBukkit;
+import be.seeseemelk.mockbukkit.ServerMock;
import com.mojang.brigadier.LiteralMessage;
import com.mojang.brigadier.Message;
import com.mojang.brigadier.ParseResults;
@@ -74,6 +76,8 @@
import org.bukkit.scoreboard.Scoreboard;
import org.bukkit.scoreboard.Team;
import org.bukkit.util.EulerAngle;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
import java.util.*;
import java.util.Map.Entry;
@@ -2519,6 +2523,60 @@ void subcommands() {
/* ANCHOR_END: subcommands4 */
}
+class test {
+class Main extends JavaPlugin {
+
+}
+
+class example1 {
+/* ANCHOR: testLoadMockCommandAPI1 */
+@BeforeEach
+public void setUp() {
+ // Set up MockBukkit server
+ ServerMock server = MockBukkit.mock();
+
+ // Load the CommandAPI plugin
+ MockCommandAPIPlugin.load(config -> config
+ .missingExecutorImplementationMessage("This command cannot be run by %S")
+ );
+
+ // Load our plugin
+ MockBukkit.load(Main.class);
+}
+
+@AfterEach
+public void tearDown() {
+ // Reset for a clean slate next test
+ MockBukkit.unmock();
+}
+/* ANCHOR_END: testLoadMockCommandAPI1 */
+}
+
+class example2 {
+/* ANCHOR: testLoadMockCommandAPI2 */
+public class CustomMockCommandAPIBukkit extends MockCommandAPIBukkit {
+ // Implement a method that usually throws `UnimplementedMethodException`
+ @Override
+ public void reloadDataPacks() {
+ CommandAPI.logInfo("Simulating data pack reload");
+ // Further logic
+ }
+}
+
+@BeforeEach
+public void setUp() {
+ // Set up MockBukkit server
+ MockBukkit.mock();
+
+ // Tell the CommandAPI to use your custom platform implementation
+ CommandAPIVersionHandler.usePlatformImplementation(new CustomMockCommandAPIBukkit());
+
+ // Load CommandAPI and your plugin as mentioned above...
+}
+/* ANCHOR_END: testLoadMockCommandAPI2 */
+}
+}
+
class tooltips {
{
/* ANCHOR: tooltips1 */
diff --git a/commandapi-documentation-velocity-code/pom.xml b/commandapi-documentation-velocity-code/pom.xml
index 05cf0a7603..20f317c4eb 100644
--- a/commandapi-documentation-velocity-code/pom.xml
+++ b/commandapi-documentation-velocity-code/pom.xml
@@ -6,7 +6,7 @@
dev.jorel
commandapi
- 9.6.1
+ 9.7.0
commandapi-documentation-velocity-code
diff --git a/commandapi-kotlin/commandapi-bukkit-kotlin/pom.xml b/commandapi-kotlin/commandapi-bukkit-kotlin/pom.xml
index 969a549543..1536fb212e 100644
--- a/commandapi-kotlin/commandapi-bukkit-kotlin/pom.xml
+++ b/commandapi-kotlin/commandapi-bukkit-kotlin/pom.xml
@@ -7,7 +7,7 @@
dev.jorel
commandapi-kotlin
- 9.6.1
+ 9.7.0
commandapi-bukkit-kotlin
diff --git a/commandapi-kotlin/commandapi-bukkit-kotlin/src/main/kotlin/dev/jorel/commandapi/kotlindsl/CommandTreeDSL.kt b/commandapi-kotlin/commandapi-bukkit-kotlin/src/main/kotlin/dev/jorel/commandapi/kotlindsl/CommandTreeDSL.kt
index d363d7dadb..0c466dc6d5 100644
--- a/commandapi-kotlin/commandapi-bukkit-kotlin/src/main/kotlin/dev/jorel/commandapi/kotlindsl/CommandTreeDSL.kt
+++ b/commandapi-kotlin/commandapi-bukkit-kotlin/src/main/kotlin/dev/jorel/commandapi/kotlindsl/CommandTreeDSL.kt
@@ -6,6 +6,8 @@ import org.bukkit.command.CommandSender
import org.bukkit.plugin.java.JavaPlugin
import java.util.function.Predicate
+const val WILL_NOT_REGISTER = "will-not-reg"
+
inline fun commandTree(name: String, tree: CommandTree.() -> Unit = {}) = CommandTree(name).apply(tree).register()
inline fun commandTree(name: String, namespace: String, tree: CommandTree.() -> Unit = {}) = CommandTree(name).apply(tree).register(namespace)
inline fun commandTree(name: String, namespace: JavaPlugin, tree: CommandTree.() -> Unit = {}) = CommandTree(name).apply(tree).register(namespace)
@@ -17,6 +19,18 @@ inline fun CommandTree.argument(base: Argument<*>, block: Argument<*>.() -> Unit
inline fun CommandTree.optionalArgument(base: Argument<*>, optional: Boolean = false, block: Argument<*>.() -> Unit = {}): CommandTree = then(base.setOptional(true).setOptional(optional).apply(block))
+inline fun CommandTree.nestedArguments(vararg arguments: Argument<*>,block: Argument<*>.() -> Unit = {}): CommandTree = thenNested(*arguments.also { it.last().apply(block) })
+inline fun CommandTree.nested(block: CommandTree.() -> Unit): CommandTree {
+ val arguments = mutableListOf?, CommandSender?>?>()
+ object : CommandTree(WILL_NOT_REGISTER) {
+ override fun then(tree: AbstractArgumentTree<*, Argument<*>?, CommandSender?>?): CommandTree? {
+ arguments.add(tree)
+ return this
+ }
+ }.block()
+ return thenNested(arguments)
+}
+
// Integer arguments
inline fun CommandTree.integerArgument(nodeName: String, min: Int = Int.MIN_VALUE, max: Int = Int.MAX_VALUE, optional: Boolean = false, block: Argument<*>.() -> Unit = {}): CommandTree = then(IntegerArgument(nodeName, min, max).setOptional(optional).apply(block))
inline fun CommandTree.integerRangeArgument(nodeName: String, optional: Boolean = false, block: Argument<*>.() -> Unit = {}): CommandTree = then(IntegerRangeArgument(nodeName).setOptional(optional).apply(block))
@@ -123,6 +137,18 @@ inline fun Argument<*>.argument(base: Argument<*>, optional: Boolean = false, bl
inline fun Argument<*>.optionalArgument(base: Argument<*>, optional: Boolean = false, block: Argument<*>.() -> Unit = {}): Argument<*> = then(base.setOptional(true).setOptional(optional).apply(block))
+inline fun Argument<*>.nestedArguments(vararg arguments: Argument<*>, block: Argument<*>.() -> Unit = {}): Argument<*> = thenNested(*arguments.also { it.last().apply(block) })
+inline fun Argument<*>.nested(block: Argument<*>.() -> Unit): Argument<*> {
+ val arguments = mutableListOf?, CommandSender?>?>()
+ object : LiteralArgument(WILL_NOT_REGISTER) {
+ override fun then(tree: AbstractArgumentTree<*, Argument<*>?, CommandSender?>?): Argument? {
+ arguments.add(tree)
+ return this
+ }
+ }.block()
+ return thenNested(arguments)
+}
+
// Integer arguments
inline fun Argument<*>.integerArgument(nodeName: String, min: Int = Int.MIN_VALUE, max: Int = Int.MAX_VALUE, optional: Boolean = false, block: Argument<*>.() -> Unit = {}): Argument<*> = then(IntegerArgument(nodeName, min, max).setOptional(optional).apply(block))
inline fun Argument<*>.integerRangeArgument(nodeName: String, optional: Boolean = false, block: Argument<*>.() -> Unit = {}): Argument<*> = then(IntegerRangeArgument(nodeName).setOptional(optional).apply(block))
diff --git a/commandapi-kotlin/commandapi-core-kotlin/pom.xml b/commandapi-kotlin/commandapi-core-kotlin/pom.xml
index 23a9b6759a..df49259731 100644
--- a/commandapi-kotlin/commandapi-core-kotlin/pom.xml
+++ b/commandapi-kotlin/commandapi-core-kotlin/pom.xml
@@ -7,7 +7,7 @@
dev.jorel
commandapi-kotlin
- 9.6.1
+ 9.7.0
commandapi-core-kotlin
diff --git a/commandapi-kotlin/commandapi-velocity-kotlin/pom.xml b/commandapi-kotlin/commandapi-velocity-kotlin/pom.xml
index 0ac824785f..6b0ff12fce 100644
--- a/commandapi-kotlin/commandapi-velocity-kotlin/pom.xml
+++ b/commandapi-kotlin/commandapi-velocity-kotlin/pom.xml
@@ -7,7 +7,7 @@
dev.jorel
commandapi-kotlin
- 9.6.1
+ 9.7.0
commandapi-velocity-kotlin
diff --git a/commandapi-kotlin/pom.xml b/commandapi-kotlin/pom.xml
index 75262d681d..22394a072a 100644
--- a/commandapi-kotlin/pom.xml
+++ b/commandapi-kotlin/pom.xml
@@ -6,7 +6,7 @@
dev.jorel
commandapi
- 9.6.1
+ 9.7.0
commandapi-kotlin
diff --git a/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-core/pom.xml b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-core/pom.xml
index c68cc6df96..b2e5879efd 100644
--- a/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-core/pom.xml
+++ b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-core/pom.xml
@@ -6,7 +6,7 @@
commandapi-bukkit
dev.jorel
- 9.6.1
+ 9.7.0
commandapi-bukkit-core
diff --git a/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-mojang-mapped/pom.xml b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-mojang-mapped/pom.xml
index f9b32e92c7..9c7885bcc1 100644
--- a/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-mojang-mapped/pom.xml
+++ b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-mojang-mapped/pom.xml
@@ -24,7 +24,7 @@
commandapi-bukkit
dev.jorel
- 9.6.1
+ 9.7.0
4.0.0
diff --git a/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-nms/commandapi-bukkit-1.16.5/pom.xml b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-nms/commandapi-bukkit-1.16.5/pom.xml
index 0b5769318f..9254bdecc6 100644
--- a/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-nms/commandapi-bukkit-1.16.5/pom.xml
+++ b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-nms/commandapi-bukkit-1.16.5/pom.xml
@@ -21,7 +21,7 @@
commandapi-bukkit-nms
dev.jorel
- 9.6.1
+ 9.7.0
4.0.0
diff --git a/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-nms/commandapi-bukkit-1.17-common/pom.xml b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-nms/commandapi-bukkit-1.17-common/pom.xml
index d30ee841c9..3de2c85fc7 100644
--- a/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-nms/commandapi-bukkit-1.17-common/pom.xml
+++ b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-nms/commandapi-bukkit-1.17-common/pom.xml
@@ -21,7 +21,7 @@
commandapi-bukkit-nms
dev.jorel
- 9.6.1
+ 9.7.0
4.0.0
diff --git a/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-nms/commandapi-bukkit-1.17.1/pom.xml b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-nms/commandapi-bukkit-1.17.1/pom.xml
index de375f35df..c0cb876776 100644
--- a/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-nms/commandapi-bukkit-1.17.1/pom.xml
+++ b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-nms/commandapi-bukkit-1.17.1/pom.xml
@@ -19,7 +19,7 @@
commandapi-bukkit-nms
dev.jorel
- 9.6.1
+ 9.7.0
4.0.0
diff --git a/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-nms/commandapi-bukkit-1.17/pom.xml b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-nms/commandapi-bukkit-1.17/pom.xml
index 6be0110452..b6e0199b54 100644
--- a/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-nms/commandapi-bukkit-1.17/pom.xml
+++ b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-nms/commandapi-bukkit-1.17/pom.xml
@@ -21,7 +21,7 @@
commandapi-bukkit-nms
dev.jorel
- 9.6.1
+ 9.7.0
4.0.0
diff --git a/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-nms/commandapi-bukkit-1.18.2/pom.xml b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-nms/commandapi-bukkit-1.18.2/pom.xml
index 2490264fb3..d9eb2a1950 100644
--- a/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-nms/commandapi-bukkit-1.18.2/pom.xml
+++ b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-nms/commandapi-bukkit-1.18.2/pom.xml
@@ -21,7 +21,7 @@
commandapi-bukkit-nms
dev.jorel
- 9.6.1
+ 9.7.0
4.0.0
diff --git a/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-nms/commandapi-bukkit-1.18/pom.xml b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-nms/commandapi-bukkit-1.18/pom.xml
index 9077160dde..1dcfaa6cbb 100644
--- a/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-nms/commandapi-bukkit-1.18/pom.xml
+++ b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-nms/commandapi-bukkit-1.18/pom.xml
@@ -21,7 +21,7 @@
commandapi-bukkit-nms
dev.jorel
- 9.6.1
+ 9.7.0
4.0.0
diff --git a/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-nms/commandapi-bukkit-1.19-common/pom.xml b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-nms/commandapi-bukkit-1.19-common/pom.xml
index 9bc4098641..4a528cdf8b 100644
--- a/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-nms/commandapi-bukkit-1.19-common/pom.xml
+++ b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-nms/commandapi-bukkit-1.19-common/pom.xml
@@ -21,7 +21,7 @@
commandapi-bukkit-nms
dev.jorel
- 9.6.1
+ 9.7.0
4.0.0
diff --git a/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-nms/commandapi-bukkit-1.19.1/pom.xml b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-nms/commandapi-bukkit-1.19.1/pom.xml
index ab095c6af0..1e63664d43 100644
--- a/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-nms/commandapi-bukkit-1.19.1/pom.xml
+++ b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-nms/commandapi-bukkit-1.19.1/pom.xml
@@ -21,7 +21,7 @@
commandapi-bukkit-nms
dev.jorel
- 9.6.1
+ 9.7.0
4.0.0
diff --git a/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-nms/commandapi-bukkit-1.19.3/pom.xml b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-nms/commandapi-bukkit-1.19.3/pom.xml
index 3366ad4051..211c2ad59d 100644
--- a/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-nms/commandapi-bukkit-1.19.3/pom.xml
+++ b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-nms/commandapi-bukkit-1.19.3/pom.xml
@@ -21,7 +21,7 @@
commandapi-bukkit-nms
dev.jorel
- 9.6.1
+ 9.7.0
4.0.0
diff --git a/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-nms/commandapi-bukkit-1.19.4/pom.xml b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-nms/commandapi-bukkit-1.19.4/pom.xml
index a96b50193a..da1cf16163 100644
--- a/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-nms/commandapi-bukkit-1.19.4/pom.xml
+++ b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-nms/commandapi-bukkit-1.19.4/pom.xml
@@ -21,7 +21,7 @@
commandapi-bukkit-nms
dev.jorel
- 9.6.1
+ 9.7.0
4.0.0
diff --git a/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-nms/commandapi-bukkit-1.19/pom.xml b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-nms/commandapi-bukkit-1.19/pom.xml
index 62b736a5f3..fdd2c771bf 100644
--- a/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-nms/commandapi-bukkit-1.19/pom.xml
+++ b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-nms/commandapi-bukkit-1.19/pom.xml
@@ -21,7 +21,7 @@
commandapi-bukkit-nms
dev.jorel
- 9.6.1
+ 9.7.0
4.0.0
diff --git a/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-nms/commandapi-bukkit-1.20.2/pom.xml b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-nms/commandapi-bukkit-1.20.2/pom.xml
index 0b12c8c17e..eac506dd60 100644
--- a/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-nms/commandapi-bukkit-1.20.2/pom.xml
+++ b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-nms/commandapi-bukkit-1.20.2/pom.xml
@@ -21,7 +21,7 @@
commandapi-bukkit-nms
dev.jorel
- 9.6.1
+ 9.7.0
4.0.0
diff --git a/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-nms/commandapi-bukkit-1.20.3/pom.xml b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-nms/commandapi-bukkit-1.20.3/pom.xml
index 8161b8fbb5..7568cd1f72 100644
--- a/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-nms/commandapi-bukkit-1.20.3/pom.xml
+++ b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-nms/commandapi-bukkit-1.20.3/pom.xml
@@ -21,7 +21,7 @@
commandapi-bukkit-nms
dev.jorel
- 9.6.1
+ 9.7.0
4.0.0
diff --git a/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-nms/commandapi-bukkit-1.20.5/pom.xml b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-nms/commandapi-bukkit-1.20.5/pom.xml
index 3c71b9e63f..8d87770d45 100644
--- a/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-nms/commandapi-bukkit-1.20.5/pom.xml
+++ b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-nms/commandapi-bukkit-1.20.5/pom.xml
@@ -21,7 +21,7 @@
commandapi-bukkit-nms
dev.jorel
- 9.6.1
+ 9.7.0
4.0.0
diff --git a/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-nms/commandapi-bukkit-1.20/pom.xml b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-nms/commandapi-bukkit-1.20/pom.xml
index 10eea8777d..94965431b6 100644
--- a/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-nms/commandapi-bukkit-1.20/pom.xml
+++ b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-nms/commandapi-bukkit-1.20/pom.xml
@@ -21,7 +21,7 @@
commandapi-bukkit-nms
dev.jorel
- 9.6.1
+ 9.7.0
4.0.0
diff --git a/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-nms/commandapi-bukkit-1.21.2/pom.xml b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-nms/commandapi-bukkit-1.21.2/pom.xml
index 89fc5cbb12..eb365e3c58 100644
--- a/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-nms/commandapi-bukkit-1.21.2/pom.xml
+++ b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-nms/commandapi-bukkit-1.21.2/pom.xml
@@ -21,7 +21,7 @@
commandapi-bukkit-nms
dev.jorel
- 9.6.1
+ 9.7.0
4.0.0
diff --git a/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-nms/commandapi-bukkit-1.21.4/pom.xml b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-nms/commandapi-bukkit-1.21.4/pom.xml
new file mode 100644
index 0000000000..b4f56a4a14
--- /dev/null
+++ b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-nms/commandapi-bukkit-1.21.4/pom.xml
@@ -0,0 +1,167 @@
+
+
+
+
+
+ commandapi-bukkit-nms
+ dev.jorel
+ 9.7.0
+
+ 4.0.0
+
+ commandapi-bukkit-1.21.4
+
+
+ 1.21.4-R0.1-SNAPSHOT
+ 1.21.4-R0.1-SNAPSHOT
+ 1.21.3-R0.1-SNAPSHOT
+
+
+
+
+ minecraft-libraries
+ https://libraries.minecraft.net
+
+
+ codemc-repo
+ https://repo.codemc.io/repository/nms/
+
+
+ papermc
+ https://repo.papermc.io/repository/maven-public/
+
+
+
+
+
+
+
+ org.spigotmc
+ spigot
+ ${spigot.version}
+ test
+
+
+
+ org.spigotmc
+ spigot
+ ${spigot.version}
+ remapped-mojang
+ provided
+
+
+
+ io.papermc.paper
+ paper-api
+ ${paper.version}
+ provided
+
+
+
+
+ dev.jorel
+ commandapi-bukkit-core
+ ${project.version}
+ compile
+
+
+
+ dev.jorel
+ commandapi-bukkit-nms-common
+ ${project.version}
+ mojang-mapped
+ provided
+
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-jar-plugin
+
+
+ Mojang-Mapped
+
+ jar
+
+
+ mojang-mapped
+
+
+
+
+
+
+ net.md-5
+ specialsource-maven-plugin
+ 2.0.2
+
+
+ package
+
+ remap
+
+ remap-obf
+
+ org.spigotmc:minecraft-server:${minecraft.mappings}:txt:maps-mojang
+ true
+ org.spigotmc:spigot:${minecraft.mappings}:jar:remapped-mojang
+ true
+ remapped-obf
+
+
+
+ package
+
+ remap
+
+ remap-spigot
+
+ ${project.build.directory}/${project.artifactId}-${project.version}-remapped-obf.jar
+ org.spigotmc:minecraft-server:${minecraft.mappings}:csrg:maps-spigot
+ org.spigotmc:spigot:${minecraft.mappings}:jar:remapped-obf
+
+
+
+
+
+
+ org.jacoco
+ jacoco-maven-plugin
+
+
+ default-prepare-agent
+
+ prepare-agent
+
+
+
+ report
+ verify
+
+ report
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-nms/commandapi-bukkit-1.21.4/src/main/java/dev/jorel/commandapi/nms/NMS_1_21_R3.java b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-nms/commandapi-bukkit-1.21.4/src/main/java/dev/jorel/commandapi/nms/NMS_1_21_R3.java
new file mode 100644
index 0000000000..39083e3fa5
--- /dev/null
+++ b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-nms/commandapi-bukkit-1.21.4/src/main/java/dev/jorel/commandapi/nms/NMS_1_21_R3.java
@@ -0,0 +1,1127 @@
+/*******************************************************************************
+ * Copyright 2024 Jorel Ali (Skepter) - MIT License
+ *
+ * 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 dev.jorel.commandapi.nms;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.PrintWriter;
+import java.io.StringWriter;
+import java.lang.reflect.Field;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.Iterator;
+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.AtomicInteger;
+import java.util.function.Predicate;
+import java.util.function.ToIntFunction;
+
+import org.bukkit.Bukkit;
+import org.bukkit.Color;
+import org.bukkit.Location;
+import org.bukkit.NamespacedKey;
+import org.bukkit.Particle;
+import org.bukkit.Particle.DustOptions;
+import org.bukkit.Particle.DustTransition;
+import org.bukkit.Particle.Trail;
+import org.bukkit.Registry;
+import org.bukkit.Vibration;
+import org.bukkit.Vibration.Destination;
+import org.bukkit.Vibration.Destination.BlockDestination;
+import org.bukkit.World;
+import org.bukkit.advancement.Advancement;
+import org.bukkit.block.Biome;
+import org.bukkit.block.Block;
+import org.bukkit.block.data.BlockData;
+import org.bukkit.command.CommandSender;
+import org.bukkit.command.SimpleCommandMap;
+import org.bukkit.craftbukkit.v1_21_R3.CraftLootTable;
+import org.bukkit.craftbukkit.v1_21_R3.CraftParticle;
+import org.bukkit.craftbukkit.v1_21_R3.CraftServer;
+import org.bukkit.craftbukkit.v1_21_R3.CraftSound;
+import org.bukkit.craftbukkit.v1_21_R3.block.data.CraftBlockData;
+import org.bukkit.craftbukkit.v1_21_R3.command.BukkitCommandWrapper;
+import org.bukkit.craftbukkit.v1_21_R3.command.VanillaCommandWrapper;
+import org.bukkit.craftbukkit.v1_21_R3.entity.CraftEntity;
+import org.bukkit.craftbukkit.v1_21_R3.help.CustomHelpTopic;
+import org.bukkit.craftbukkit.v1_21_R3.help.SimpleHelpMap;
+import org.bukkit.craftbukkit.v1_21_R3.inventory.CraftItemStack;
+import org.bukkit.craftbukkit.v1_21_R3.potion.CraftPotionEffectType;
+import org.bukkit.enchantments.Enchantment;
+import org.bukkit.entity.EntityType;
+import org.bukkit.entity.Player;
+import org.bukkit.help.HelpTopic;
+import org.bukkit.inventory.Recipe;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.io.Files;
+import com.google.gson.GsonBuilder;
+import com.mojang.brigadier.CommandDispatcher;
+import com.mojang.brigadier.Message;
+import com.mojang.brigadier.arguments.ArgumentType;
+import com.mojang.brigadier.context.CommandContext;
+import com.mojang.brigadier.exceptions.CommandSyntaxException;
+import com.mojang.brigadier.suggestion.SuggestionProvider;
+import com.mojang.brigadier.suggestion.Suggestions;
+import com.mojang.logging.LogUtils;
+import com.mojang.serialization.Codec;
+import com.mojang.serialization.DataResult;
+
+import dev.jorel.commandapi.CommandAPI;
+import dev.jorel.commandapi.CommandAPIHandler;
+import dev.jorel.commandapi.CommandRegistrationStrategy;
+import dev.jorel.commandapi.PaperCommandRegistration;
+import dev.jorel.commandapi.SafeVarHandle;
+import dev.jorel.commandapi.SpigotCommandRegistration;
+import dev.jorel.commandapi.arguments.ArgumentSubType;
+import dev.jorel.commandapi.arguments.SuggestionProviders;
+import dev.jorel.commandapi.commandsenders.AbstractCommandSender;
+import dev.jorel.commandapi.commandsenders.BukkitCommandSender;
+import dev.jorel.commandapi.commandsenders.BukkitNativeProxyCommandSender;
+import dev.jorel.commandapi.preprocessor.Differs;
+import dev.jorel.commandapi.preprocessor.NMSMeta;
+import dev.jorel.commandapi.preprocessor.RequireField;
+import dev.jorel.commandapi.wrappers.ComplexRecipeImpl;
+import dev.jorel.commandapi.wrappers.FloatRange;
+import dev.jorel.commandapi.wrappers.FunctionWrapper;
+import dev.jorel.commandapi.wrappers.IntegerRange;
+import dev.jorel.commandapi.wrappers.Location2D;
+import dev.jorel.commandapi.wrappers.NativeProxyCommandSender;
+import dev.jorel.commandapi.wrappers.ParticleData;
+import dev.jorel.commandapi.wrappers.ScoreboardSlot;
+import dev.jorel.commandapi.wrappers.SimpleFunctionWrapper;
+import net.kyori.adventure.text.Component;
+import net.kyori.adventure.text.format.NamedTextColor;
+import net.kyori.adventure.text.serializer.gson.GsonComponentSerializer;
+import net.md_5.bungee.api.chat.BaseComponent;
+import net.md_5.bungee.chat.ComponentSerializer;
+import net.minecraft.advancements.AdvancementHolder;
+import net.minecraft.advancements.critereon.MinMaxBounds;
+import net.minecraft.commands.CommandBuildContext;
+import net.minecraft.commands.CommandResultCallback;
+import net.minecraft.commands.CommandSourceStack;
+import net.minecraft.commands.FunctionInstantiationException;
+import net.minecraft.commands.SharedSuggestionProvider;
+import net.minecraft.commands.arguments.ColorArgument;
+import net.minecraft.commands.arguments.ComponentArgument;
+import net.minecraft.commands.arguments.DimensionArgument;
+import net.minecraft.commands.arguments.EntityArgument;
+import net.minecraft.commands.arguments.MessageArgument;
+import net.minecraft.commands.arguments.ParticleArgument;
+import net.minecraft.commands.arguments.RangeArgument;
+import net.minecraft.commands.arguments.ResourceArgument;
+import net.minecraft.commands.arguments.ResourceKeyArgument;
+import net.minecraft.commands.arguments.ResourceLocationArgument;
+import net.minecraft.commands.arguments.ScoreHolderArgument;
+import net.minecraft.commands.arguments.ScoreboardSlotArgument;
+import net.minecraft.commands.arguments.blocks.BlockPredicateArgument;
+import net.minecraft.commands.arguments.blocks.BlockStateArgument;
+import net.minecraft.commands.arguments.coordinates.BlockPosArgument;
+import net.minecraft.commands.arguments.coordinates.ColumnPosArgument;
+import net.minecraft.commands.arguments.coordinates.Vec2Argument;
+import net.minecraft.commands.arguments.coordinates.Vec3Argument;
+import net.minecraft.commands.arguments.item.FunctionArgument;
+import net.minecraft.commands.arguments.item.ItemArgument;
+import net.minecraft.commands.arguments.item.ItemInput;
+import net.minecraft.commands.arguments.item.ItemPredicateArgument;
+import net.minecraft.commands.arguments.selector.EntitySelector;
+import net.minecraft.commands.execution.ExecutionContext;
+import net.minecraft.commands.functions.CommandFunction;
+import net.minecraft.commands.functions.InstantiatedFunction;
+import net.minecraft.commands.synchronization.ArgumentUtils;
+import net.minecraft.core.BlockPos;
+import net.minecraft.core.Holder;
+import net.minecraft.core.particles.BlockParticleOption;
+import net.minecraft.core.particles.ColorParticleOption;
+import net.minecraft.core.particles.DustColorTransitionOptions;
+import net.minecraft.core.particles.DustParticleOptions;
+import net.minecraft.core.particles.ItemParticleOption;
+import net.minecraft.core.particles.ParticleOptions;
+import net.minecraft.core.particles.SculkChargeParticleOptions;
+import net.minecraft.core.particles.ShriekParticleOption;
+import net.minecraft.core.particles.SimpleParticleType;
+import net.minecraft.core.particles.TrailParticleOption;
+import net.minecraft.core.particles.VibrationParticleOption;
+import net.minecraft.core.registries.BuiltInRegistries;
+import net.minecraft.core.registries.Registries;
+import net.minecraft.nbt.CompoundTag;
+import net.minecraft.nbt.NbtOps;
+import net.minecraft.network.chat.Component.Serializer;
+import net.minecraft.resources.ResourceLocation;
+import net.minecraft.server.MinecraftServer;
+import net.minecraft.server.MinecraftServer.ReloadableResources;
+import net.minecraft.server.ServerFunctionLibrary;
+import net.minecraft.server.ServerFunctionManager;
+import net.minecraft.server.level.ColumnPos;
+import net.minecraft.server.level.ServerPlayer;
+import net.minecraft.server.packs.PackResources;
+import net.minecraft.server.packs.PackType;
+import net.minecraft.server.packs.repository.Pack;
+import net.minecraft.server.packs.repository.PackRepository;
+import net.minecraft.server.packs.resources.MultiPackResourceManager;
+import net.minecraft.server.packs.resources.SimpleReloadInstance;
+import net.minecraft.sounds.SoundEvent;
+import net.minecraft.util.Unit;
+import net.minecraft.util.profiling.Profiler;
+import net.minecraft.util.profiling.ProfilerFiller;
+import net.minecraft.world.entity.Entity;
+import net.minecraft.world.flag.FeatureFlagSet;
+import net.minecraft.world.item.ItemStack;
+import net.minecraft.world.item.crafting.RecipeHolder;
+import net.minecraft.world.level.DataPackConfig;
+import net.minecraft.world.level.Level;
+import net.minecraft.world.level.WorldDataConfiguration;
+import net.minecraft.world.level.block.entity.FuelValues;
+import net.minecraft.world.level.block.state.pattern.BlockInWorld;
+import net.minecraft.world.level.gameevent.BlockPositionSource;
+import net.minecraft.world.phys.Vec2;
+import net.minecraft.world.phys.Vec3;
+import net.minecraft.world.scores.ScoreHolder;
+
+// Mojang-Mapped reflection
+/**
+ * NMS implementation for Minecraft 1.21.4
+ */
+@NMSMeta(compatibleWith = { "1.21.4" })
+@RequireField(in = SimpleHelpMap.class, name = "helpTopics", ofType = Map.class)
+@RequireField(in = EntitySelector.class, name = "usesSelector", ofType = boolean.class)
+// @RequireField(in = ItemInput.class, name = "tag", ofType = CompoundTag.class)
+@RequireField(in = ServerFunctionLibrary.class, name = "dispatcher", ofType = CommandDispatcher.class)
+@RequireField(in = MinecraftServer.class, name = "fuelValues", ofType = FuelValues.class)
+public class NMS_1_21_R3 extends NMS_Common {
+
+ private static final SafeVarHandle> helpMapTopics;
+ private static final Field entitySelectorUsesSelector;
+ // private static final SafeVarHandle itemInput;
+ private static final Field serverFunctionLibraryDispatcher;
+ private static final boolean vanillaCommandDispatcherFieldExists;
+ private static final SafeVarHandle minecraftServerFuelValues;
+
+ // Derived from net.minecraft.commands.Commands;
+ private static final CommandBuildContext COMMAND_BUILD_CONTEXT;
+
+ // Compute all var handles all in one go so we don't do this during main server
+ // runtime
+ static {
+ if (Bukkit.getServer() instanceof CraftServer server) {
+ COMMAND_BUILD_CONTEXT = CommandBuildContext.simple(server.getServer().registryAccess(),
+ server.getServer().getWorldData().getDataConfiguration().enabledFeatures());
+ } else {
+ COMMAND_BUILD_CONTEXT = null;
+ }
+
+ helpMapTopics = SafeVarHandle.ofOrNull(SimpleHelpMap.class, "helpTopics", "helpTopics", Map.class);
+ // For some reason, MethodHandles fails for this field, but Field works okay
+ entitySelectorUsesSelector = CommandAPIHandler.getField(EntitySelector.class, "p", "usesSelector");
+ // itemInput = SafeVarHandle.ofOrNull(ItemInput.class, "c", "tag", CompoundTag.class);
+ // For some reason, MethodHandles fails for this field, but Field works okay
+ serverFunctionLibraryDispatcher = CommandAPIHandler.getField(ServerFunctionLibrary.class, "h", "dispatcher");
+
+ boolean fieldExists;
+ try {
+ MinecraftServer.class.getDeclaredField("vanillaCommandDispatcher");
+ fieldExists = true;
+ } catch (NoSuchFieldException | SecurityException e) {
+ // Expected on Paper-1.20.6-65 or later due to https://github.com/PaperMC/Paper/pull/8235
+ fieldExists = false;
+ }
+ vanillaCommandDispatcherFieldExists = fieldExists;
+
+ minecraftServerFuelValues = SafeVarHandle.ofOrNull(MinecraftServer.class, "aE", "fuelValues", FuelValues.class);
+ }
+
+ private static NamespacedKey fromResourceLocation(ResourceLocation key) {
+ return NamespacedKey.fromString(key.getNamespace() + ":" + key.getPath());
+ }
+
+ @Differs(from = "1.21.1", by = "New advancement argument implementation")
+ @Override
+ public ArgumentType> _ArgumentAdvancement() {
+ return ResourceKeyArgument.key(Registries.ADVANCEMENT);
+ }
+
+ @Override
+ public final ArgumentType> _ArgumentBlockPredicate() {
+ return BlockPredicateArgument.blockPredicate(COMMAND_BUILD_CONTEXT);
+ }
+
+ @Override
+ public final ArgumentType> _ArgumentBlockState() {
+ return BlockStateArgument.block(COMMAND_BUILD_CONTEXT);
+ }
+
+ @Override
+ public ArgumentType> _ArgumentChatComponent() {
+ return ComponentArgument.textComponent(COMMAND_BUILD_CONTEXT);
+ }
+
+ @Override
+ public final ArgumentType> _ArgumentEnchantment() {
+ return ResourceArgument.resource(COMMAND_BUILD_CONTEXT, Registries.ENCHANTMENT);
+ }
+
+ @Override
+ public final ArgumentType> _ArgumentEntity(ArgumentSubType subType) {
+ return switch (subType) {
+ case ENTITYSELECTOR_MANY_ENTITIES -> EntityArgument.entities();
+ case ENTITYSELECTOR_MANY_PLAYERS -> EntityArgument.players();
+ case ENTITYSELECTOR_ONE_ENTITY -> EntityArgument.entity();
+ case ENTITYSELECTOR_ONE_PLAYER -> EntityArgument.player();
+ default -> throw new IllegalArgumentException("Unexpected value: " + subType);
+ };
+ }
+
+ @Override
+ public final ArgumentType> _ArgumentItemPredicate() {
+ return ItemPredicateArgument.itemPredicate(COMMAND_BUILD_CONTEXT);
+ }
+
+ @Override
+ public final ArgumentType> _ArgumentItemStack() {
+ return ItemArgument.item(COMMAND_BUILD_CONTEXT);
+ }
+
+ @Override
+ public final ArgumentType> _ArgumentParticle() {
+ return ParticleArgument.particle(COMMAND_BUILD_CONTEXT);
+ }
+
+ @Differs(from = "1.21.1", by = "New recipe argument implementation")
+ @Override
+ public ArgumentType> _ArgumentRecipe() {
+ return ResourceKeyArgument.key(Registries.RECIPE);
+ }
+
+ @Override
+ public final ArgumentType> _ArgumentSyntheticBiome() {
+ return ResourceArgument.resource(COMMAND_BUILD_CONTEXT, Registries.BIOME);
+ }
+
+ @Override
+ public final Map getHelpMap() {
+ return helpMapTopics.get((SimpleHelpMap) Bukkit.getHelpMap());
+ }
+
+ @Override
+ public String[] compatibleVersions() {
+ return new String[] { "1.21.4" };
+ };
+
+ @Differs(from = "1.20.6", by = "ItemInput constructor uses a data components patch, instead of a data components map")
+ private static String serializeNMSItemStack(ItemStack is) {
+ return new ItemInput(is.getItemHolder(), is.getComponentsPatch()).serialize(COMMAND_BUILD_CONTEXT);
+ }
+
+ @Override
+ public final String convert(org.bukkit.inventory.ItemStack is) {
+ return serializeNMSItemStack(CraftItemStack.asNMSCopy(is));
+ }
+
+ @SuppressWarnings({ "rawtypes", "unchecked" })
+ @Override
+ public final String convert(ParticleData> particle) {
+ final ParticleOptions particleOptions = CraftParticle.createParticleParam(particle.particle(), particle.data());
+ final ResourceLocation particleKey = BuiltInRegistries.PARTICLE_TYPE.getKey(particleOptions.getType());
+
+ // /particle dust{scale:2,color:[1,2,2]}
+ // Use the particle option's codec to convert the data into NBT. If we have any tags, add them
+ // to the end, otherwise leave it as it is (e.g. `/particle crit` as opposed to `/particle crit{}`)
+ final Codec codec = particleOptions.getType().codec().codec();
+ final DataResult result = codec.encodeStart(NbtOps.INSTANCE, particleOptions);
+ final CompoundTag particleOptionsTag = result.result().get();
+ final String dataString = particleOptionsTag.getAllKeys().isEmpty() ? "" : particleOptionsTag.getAsString();
+ return particleKey.toString() + dataString;
+ }
+
+ /**
+ * An implementation of {@link ServerFunctionManager#execute(CommandFunction, CommandSourceStack)} with a specified
+ * command result callback instead of {@link CommandResultCallback.EMPTY}
+ * @param commandFunction the command function to run
+ * @param css the command source stack to execute this command
+ * @return the result of our function. This is either 0 is the command failed, or greater than 0 if the command succeeded
+ */
+ @Differs(from = "1.21.1", by = "Use Profiler.get() instead of MinecraftServer.getProfiler()")
+ private final int runCommandFunction(CommandFunction commandFunction, CommandSourceStack css) {
+ // Profile the function. We want to simulate the execution sequence exactly
+ ProfilerFiller profiler = Profiler.get();
+ profiler.push(() -> "function " + commandFunction.id());
+
+ // Store our function result
+ AtomicInteger result = new AtomicInteger();
+ CommandResultCallback onCommandResult = (succeeded, resultValue) -> result.set(resultValue);
+
+ try {
+ final InstantiatedFunction instantiatedFunction = commandFunction.instantiate((CompoundTag) null, this.getBrigadierDispatcher());
+ net.minecraft.commands.Commands.executeCommandInContext(css, (executioncontext) -> {
+ ExecutionContext.queueInitialFunctionCall(executioncontext, instantiatedFunction, css, onCommandResult);
+ });
+ } catch (FunctionInstantiationException functionInstantiationException) {
+ // We don't care if the function failed to instantiate
+ assert true;
+ } catch (Exception exception) {
+ LogUtils.getLogger().warn("Failed to execute function {}", commandFunction.id(), exception);
+ } finally {
+ profiler.pop();
+ }
+
+ return result.get();
+ }
+
+ // Converts NMS function to SimpleFunctionWrapper
+ private final SimpleFunctionWrapper convertFunction(CommandFunction commandFunction) {
+ ToIntFunction appliedObj = (CommandSourceStack css) -> runCommandFunction(commandFunction, css);
+
+ // Unpack the commands by instantiating the function with no CSS, then retrieving its entries
+ String[] commands = new String[0];
+ try {
+ final InstantiatedFunction instantiatedFunction = commandFunction.instantiate((CompoundTag) null, this.getBrigadierDispatcher());
+
+ List> cArr = instantiatedFunction.entries();
+ commands = new String[cArr.size()];
+ for (int i = 0, size = cArr.size(); i < size; i++) {
+ commands[i] = cArr.get(i).toString();
+ }
+ } catch (FunctionInstantiationException functionInstantiationException) {
+ // We don't care if the function failed to instantiate
+ assert true;
+ }
+ return new SimpleFunctionWrapper(fromResourceLocation(commandFunction.id()), appliedObj, commands);
+ }
+
+ @Override
+ public final void createDispatcherFile(File file, CommandDispatcher dispatcher)
+ throws IOException {
+ Files.asCharSink(file, StandardCharsets.UTF_8).write(new GsonBuilder().setPrettyPrinting().create()
+ .toJson(ArgumentUtils.serializeNodeToJson(dispatcher, dispatcher.getRoot())));
+ }
+
+ @Override
+ public final HelpTopic generateHelpTopic(String commandName, String shortDescription, String fullDescription,
+ String permission) {
+ return new CustomHelpTopic(commandName, shortDescription, fullDescription, permission);
+ }
+
+ @Differs(from = "1.21.1", by = "Uses ResourceKeyArgument instead of ResourceLocationArgument")
+ @Override
+ public Advancement getAdvancement(CommandContext cmdCtx, String key)
+ throws CommandSyntaxException {
+ return ResourceKeyArgument.getAdvancement(cmdCtx, key).toBukkit();
+ }
+
+ @Override
+ public Component getAdventureChat(CommandContext cmdCtx, String key) throws CommandSyntaxException {
+ return GsonComponentSerializer.gson().deserialize(Serializer.toJson(MessageArgument.getMessage(cmdCtx, key), COMMAND_BUILD_CONTEXT));
+ }
+
+ @Override
+ public NamedTextColor getAdventureChatColor(CommandContext cmdCtx, String key) {
+ final Integer color = ColorArgument.getColor(cmdCtx, key).getColor();
+ return color == null ? NamedTextColor.WHITE : NamedTextColor.namedColor(color);
+ }
+
+ @Override
+ public final Component getAdventureChatComponent(CommandContext cmdCtx, String key) {
+ return GsonComponentSerializer.gson()
+ .deserialize(Serializer.toJson(ComponentArgument.getComponent(cmdCtx, key), COMMAND_BUILD_CONTEXT));
+ }
+
+ @Override
+ public final Object getBiome(CommandContext cmdCtx, String key, ArgumentSubType subType)
+ throws CommandSyntaxException {
+ final ResourceLocation resourceLocation = ResourceArgument.getResource(cmdCtx, key, Registries.BIOME).key()
+ .location();
+ return switch (subType) {
+ case BIOME_BIOME -> {
+ Biome biome = null;
+ try {
+ biome = Biome.valueOf(resourceLocation.getPath().toUpperCase());
+ } catch (IllegalArgumentException biomeNotFound) {
+ biome = null;
+ }
+ yield biome;
+ }
+ case BIOME_NAMESPACEDKEY -> (NamespacedKey) fromResourceLocation(resourceLocation);
+ default -> null;
+ };
+ }
+
+ @Override
+ public final Predicate getBlockPredicate(CommandContext cmdCtx, String key)
+ throws CommandSyntaxException {
+ Predicate predicate = BlockPredicateArgument.getBlockPredicate(cmdCtx, key);
+ return (Block block) -> predicate.test(new BlockInWorld(cmdCtx.getSource().getLevel(),
+ new BlockPos(block.getX(), block.getY(), block.getZ()), true));
+ }
+
+ @Override
+ public final BlockData getBlockState(CommandContext cmdCtx, String key) {
+ return CraftBlockData.fromData(BlockStateArgument.getBlock(cmdCtx, key).getState());
+ }
+
+ @Override
+ public CommandSourceStack getBrigadierSourceFromCommandSender(
+ AbstractCommandSender extends CommandSender> sender) {
+ return VanillaCommandWrapper.getListener(sender.getSource());
+ }
+
+ @Override
+ public final BaseComponent[] getChat(CommandContext cmdCtx, String key) throws CommandSyntaxException {
+ return ComponentSerializer.parse(Serializer.toJson(MessageArgument.getMessage(cmdCtx, key), COMMAND_BUILD_CONTEXT));
+ }
+
+ @Override
+ public final World getDimension(CommandContext cmdCtx, String key)
+ throws CommandSyntaxException {
+ return DimensionArgument.getDimension(cmdCtx, key).getWorld();
+ }
+
+ @Override
+ public final Enchantment getEnchantment(CommandContext cmdCtx, String key)
+ throws CommandSyntaxException {
+ final net.minecraft.world.item.enchantment.Enchantment enchantment = ResourceArgument.getEnchantment(cmdCtx, key).value();
+ final ResourceLocation resource = this.getMinecraftServer().registryAccess().lookupOrThrow(Registries.ENCHANTMENT).getKey(enchantment);
+ return Registry.ENCHANTMENT.get(fromResourceLocation(resource));
+ }
+
+ @Override
+ public final Object getEntitySelector(CommandContext cmdCtx, String str,
+ ArgumentSubType subType, boolean allowEmpty) throws CommandSyntaxException {
+
+ // We override the rule whereby players need "minecraft.command.selector" and
+ // have to have level 2 permissions in order to use entity selectors. We're
+ // trying to allow entity selectors to be used by anyone that registers a
+ // command via the CommandAPI.
+ EntitySelector argument = cmdCtx.getArgument(str, EntitySelector.class);
+ try {
+ entitySelectorUsesSelector.set(argument, false);
+ } catch (IllegalAccessException e) {
+ // Shouldn't happen, CommandAPIHandler#getField makes it accessible
+ }
+
+ return switch (subType) {
+ case ENTITYSELECTOR_MANY_ENTITIES:
+ try {
+ List result = new ArrayList<>();
+ for (Entity entity : argument.findEntities(cmdCtx.getSource())) {
+ result.add(entity.getBukkitEntity());
+ }
+ if (result.isEmpty() && !allowEmpty) {
+ throw EntityArgument.NO_ENTITIES_FOUND.create();
+ } else {
+ yield result;
+ }
+ } catch (CommandSyntaxException e) {
+ if (allowEmpty) {
+ yield new ArrayList();
+ } else {
+ throw e;
+ }
+ }
+ case ENTITYSELECTOR_MANY_PLAYERS:
+ try {
+ List result = new ArrayList<>();
+ for (ServerPlayer player : argument.findPlayers(cmdCtx.getSource())) {
+ result.add(player.getBukkitEntity());
+ }
+ if (result.isEmpty() && !allowEmpty) {
+ throw EntityArgument.NO_PLAYERS_FOUND.create();
+ } else {
+ yield result;
+ }
+ } catch (CommandSyntaxException e) {
+ if (allowEmpty) {
+ yield new ArrayList();
+ } else {
+ throw e;
+ }
+ }
+ case ENTITYSELECTOR_ONE_ENTITY:
+ yield argument.findSingleEntity(cmdCtx.getSource()).getBukkitEntity();
+ case ENTITYSELECTOR_ONE_PLAYER:
+ yield argument.findSinglePlayer(cmdCtx.getSource()).getBukkitEntity();
+ default:
+ throw new IllegalArgumentException("Unexpected value: " + subType);
+ };
+ }
+
+ @SuppressWarnings("deprecation")
+ @Override
+ public final EntityType getEntityType(CommandContext cmdCtx, String key)
+ throws CommandSyntaxException {
+ return EntityType.fromName(net.minecraft.world.entity.EntityType
+ .getKey(ResourceArgument.getSummonableEntityType(cmdCtx, key).value()).getPath());
+ }
+
+ @Override
+ public FloatRange getFloatRange(CommandContext cmdCtx, String key) {
+ MinMaxBounds.Doubles range = RangeArgument.Floats.getRange(cmdCtx, key);
+ final Double lowBoxed = range.min().orElse(null);
+ final Double highBoxed = range.max().orElse(null);
+ final double low = lowBoxed == null ? -Float.MAX_VALUE : lowBoxed;
+ final double high = highBoxed == null ? Float.MAX_VALUE : highBoxed;
+ return new FloatRange((float) low, (float) high);
+ }
+
+ @Override
+ public final FunctionWrapper[] getFunction(CommandContext cmdCtx, String key)
+ throws CommandSyntaxException {
+ List result = new ArrayList<>();
+ CommandSourceStack css = cmdCtx.getSource().withSuppressedOutput().withMaximumPermission(2);
+
+ for (CommandFunction commandFunction : FunctionArgument.getFunctions(cmdCtx, key)) {
+ result.add(FunctionWrapper.fromSimpleFunctionWrapper(convertFunction(commandFunction), css,
+ entity -> cmdCtx.getSource().withEntity(((CraftEntity) entity).getHandle())));
+ }
+ return result.toArray(new FunctionWrapper[0]);
+ }
+
+ @Differs(from = "1.20.6", by = "ResourceLocation constructor change to static ResourceLocation#fromNamespaceAndPath")
+ @Override
+ public SimpleFunctionWrapper getFunction(NamespacedKey key) {
+ final ResourceLocation resourceLocation = ResourceLocation.fromNamespaceAndPath(key.getNamespace(), key.getKey());
+ Optional> commandFunctionOptional = this
+ .getMinecraftServer().getFunctions().get(resourceLocation);
+ if (commandFunctionOptional.isPresent()) {
+ return convertFunction(commandFunctionOptional.get());
+ } else {
+ throw new IllegalStateException("Failed to get defined function " + key
+ + "! This should never happen - please report this to the CommandAPI"
+ + "developers, we'd love to know how you got this error message!");
+ }
+ }
+
+ @Override
+ public Set getFunctions() {
+ Set result = new HashSet<>();
+ for (ResourceLocation resourceLocation : this.getMinecraftServer().getFunctions()
+ .getFunctionNames()) {
+ result.add(fromResourceLocation(resourceLocation));
+ }
+ return result;
+ }
+
+ @Override
+ public IntegerRange getIntRange(CommandContext cmdCtx, String key) {
+ MinMaxBounds.Ints range = RangeArgument.Ints.getRange(cmdCtx, key);
+ final Integer lowBoxed = range.min().orElse(null);
+ final Integer highBoxed = range.max().orElse(null);
+ final int low = lowBoxed == null ? Integer.MIN_VALUE : lowBoxed;
+ final int high = highBoxed == null ? Integer.MAX_VALUE : highBoxed;
+ return new IntegerRange(low, high);
+ }
+
+ @Override
+ public final org.bukkit.inventory.ItemStack getItemStack(CommandContext cmdCtx, String key)
+ throws CommandSyntaxException {
+ ItemInput input = ItemArgument.getItem(cmdCtx, key);
+
+ // Create the basic ItemStack with an amount of 1
+ net.minecraft.world.item.ItemStack item = input.createItemStack(1, false);
+ return CraftItemStack.asBukkitCopy(item);
+ }
+
+ @Override
+ public final Predicate getItemStackPredicate(
+ CommandContext cmdCtx, String key) throws CommandSyntaxException {
+ // Not inside the lambda because getItemPredicate throws CommandSyntaxException
+ Predicate predicate = ItemPredicateArgument.getItemPredicate(cmdCtx, key);
+ return item -> predicate.test(CraftItemStack.asNMSCopy(item));
+ }
+
+ @Override
+ public final Location2D getLocation2DBlock(CommandContext cmdCtx, String key)
+ throws CommandSyntaxException {
+ ColumnPos blockPos = ColumnPosArgument.getColumnPos(cmdCtx, key);
+ return new Location2D(getWorldForCSS(cmdCtx.getSource()), blockPos.x(), blockPos.z());
+ }
+
+ @Override
+ public final Location2D getLocation2DPrecise(CommandContext cmdCtx, String key)
+ throws CommandSyntaxException {
+ Vec2 vecPos = Vec2Argument.getVec2(cmdCtx, key);
+ return new Location2D(getWorldForCSS(cmdCtx.getSource()), vecPos.x, vecPos.y);
+ }
+
+ @Override
+ public final Location getLocationBlock(CommandContext cmdCtx, String key)
+ throws CommandSyntaxException {
+ BlockPos blockPos = BlockPosArgument.getSpawnablePos(cmdCtx, key);
+ return new Location(getWorldForCSS(cmdCtx.getSource()), blockPos.getX(), blockPos.getY(), blockPos.getZ());
+ }
+
+ @Override
+ public final Location getLocationPrecise(CommandContext cmdCtx, String key)
+ throws CommandSyntaxException {
+ Vec3 vecPos = Vec3Argument.getCoordinates(cmdCtx, key).getPosition(cmdCtx.getSource());
+ return new Location(getWorldForCSS(cmdCtx.getSource()), vecPos.x(), vecPos.y(), vecPos.z());
+ }
+
+ @Override
+ public final org.bukkit.loot.LootTable getLootTable(CommandContext cmdCtx, String key) {
+ return CraftLootTable.minecraftToBukkit(ResourceLocationArgument.getId(cmdCtx, key));
+ }
+
+ @Override
+ public NamespacedKey getMinecraftKey(CommandContext cmdCtx, String key) {
+ return fromResourceLocation(ResourceLocationArgument.getId(cmdCtx, key));
+ }
+
+ @Override
+ public final ParticleData> getParticle(CommandContext cmdCtx, String key) {
+ final ParticleOptions particleOptions = ParticleArgument.getParticle(cmdCtx, key);
+
+ // In our test suite, we can't parse particles via CraftParticle.minecraftToBukkit
+ // on 1.20.3+ because initializing CraftParticle's static constructor requires
+ // implementing a registry. We don't care about a registry for the sake of testing,
+ // all we're actually interested in is testing that the particle data is being parsed
+ // and converted to Bukkit properly, because that's what actually matters. If the
+ // Bukkit#getServer is a CraftServer, that means we're running on a normal (Bukkit/Spigot/Paper)
+ // server. If it isn't, that means we're running in our test environment (i.e. a mocked
+ // server instance), or some weird flavour of Bukkit that we're not supposed to use.
+ final Particle particle;
+ if (Bukkit.getServer() instanceof CraftServer server) {
+ particle = CraftParticle.minecraftToBukkit(particleOptions.getType());
+ } else {
+ particle = null;
+ }
+
+ if (particleOptions instanceof SimpleParticleType) {
+ return new ParticleData(particle, null);
+ } else if (particleOptions instanceof BlockParticleOption options) {
+ return new ParticleData(particle, CraftBlockData.fromData(options.getState()));
+ } else if (particleOptions instanceof DustColorTransitionOptions options) {
+ return getParticleDataAsDustColorTransitionOption(particle, options);
+ } else if (particleOptions instanceof DustParticleOptions options) {
+ final Color color = Color.fromRGB((int) (options.getColor().x() * 255.0F),
+ (int) (options.getColor().y() * 255.0F), (int) (options.getColor().z() * 255.0F));
+ return new ParticleData(particle, new DustOptions(color, options.getScale()));
+ } else if (particleOptions instanceof ItemParticleOption options) {
+ return new ParticleData(particle,
+ CraftItemStack.asBukkitCopy(options.getItem()));
+ } else if (particleOptions instanceof VibrationParticleOption options) {
+ return getParticleDataAsVibrationParticleOption(cmdCtx, particle, options);
+ } else if (particleOptions instanceof ShriekParticleOption options) {
+ // CraftBukkit implements shriek particles as a (boxed) Integer object
+ return new ParticleData(particle, Integer.valueOf(options.getDelay()));
+ } else if (particleOptions instanceof SculkChargeParticleOptions options) {
+ // CraftBukkit implements sculk charge particles as a (boxed) Float object
+ return new ParticleData(particle, Float.valueOf(options.roll()));
+ } else if (particleOptions instanceof ColorParticleOption options) {
+ return getParticleDataAsColorParticleOption(particle, options);
+ } else if (particleOptions instanceof TrailParticleOption options) {
+ return getParticleDataAsTrailParticleOption(cmdCtx, particle, options);
+ } else {
+ CommandAPI.getLogger().warning("Invalid particle data type for " + particle.getDataType().toString());
+ return new ParticleData(particle, null);
+ }
+ }
+
+ private ParticleData getParticleDataAsTrailParticleOption(CommandContext cmdCtx,
+ Particle particle, TrailParticleOption options) {
+ final Level level = cmdCtx.getSource().getLevel();
+ final Vec3 target = options.target();
+ final Location targetLocation = new Location(level.getWorld(), target.x, target.y, target.z);
+ final Color color = Color.fromARGB(options.color());
+ return new ParticleData(particle, new Trail(targetLocation, color, options.duration()));
+ }
+
+ private ParticleData getParticleDataAsColorParticleOption(Particle particle,
+ ColorParticleOption options) {
+ final Color color = Color.fromARGB(
+ (int) (options.getAlpha() * 255.0F),
+ (int) (options.getRed() * 255.0F),
+ (int) (options.getGreen() * 255.0F),
+ (int) (options.getBlue() * 255.0F)
+ );
+ return new ParticleData(particle, color);
+ }
+
+ private ParticleData getParticleDataAsDustColorTransitionOption(Particle particle,
+ DustColorTransitionOptions options) {
+ final Color color = Color.fromRGB((int) (options.getFromColor().x() * 255.0F),
+ (int) (options.getFromColor().y() * 255.0F), (int) (options.getFromColor().z() * 255.0F));
+ final Color toColor = Color.fromRGB((int) (options.getToColor().x() * 255.0F),
+ (int) (options.getToColor().y() * 255.0F), (int) (options.getToColor().z() * 255.0F));
+ return new ParticleData(particle, new DustTransition(color, toColor, options.getScale()));
+ }
+
+ private ParticleData> getParticleDataAsVibrationParticleOption(CommandContext cmdCtx,
+ Particle particle, VibrationParticleOption options) {
+ // The "from" part of the Vibration object in Bukkit is completely ignored now,
+ // so we just populate it with some "feasible" information
+ final Vec3 origin = cmdCtx.getSource().getPosition();
+ Level level = cmdCtx.getSource().getLevel();
+ Location from = new Location(level.getWorld(), origin.x, origin.y, origin.z);
+ final Destination destination;
+
+ if (options.getDestination() instanceof BlockPositionSource positionSource) {
+ Vec3 to = positionSource.getPosition(level).get();
+ destination = new BlockDestination(new Location(level.getWorld(), to.x(), to.y(), to.z()));
+ } else {
+ CommandAPI.getLogger().warning("Unknown or unsupported vibration destination " + options.getDestination());
+ return new ParticleData(particle, null);
+ }
+ return new ParticleData(particle, new Vibration(from, destination, options.getArrivalInTicks()));
+ }
+
+ @Override
+ public Object getPotionEffect(CommandContext cmdCtx, String key, ArgumentSubType subType) throws CommandSyntaxException {
+ return switch (subType) {
+ case POTION_EFFECT_POTION_EFFECT -> CraftPotionEffectType.minecraftToBukkit(ResourceArgument.getMobEffect(cmdCtx, key).value());
+ case POTION_EFFECT_NAMESPACEDKEY -> fromResourceLocation(ResourceLocationArgument.getId(cmdCtx, key));
+ default -> throw new IllegalArgumentException("Unexpected value: " + subType);
+ };
+ }
+
+ @Differs(from = "1.21.1", by = "Uses ResourceKeyArgument instead of ResourceLocationArgument")
+ @Override
+ public final Recipe getRecipe(CommandContext cmdCtx, String key) throws CommandSyntaxException {
+ RecipeHolder> recipe = ResourceKeyArgument.getRecipe(cmdCtx, key);
+ return new ComplexRecipeImpl(fromResourceLocation(recipe.id().registry()), recipe.toBukkitRecipe());
+ }
+
+ @Override
+ public ScoreboardSlot getScoreboardSlot(CommandContext cmdCtx, String key) {
+ return ScoreboardSlot.ofMinecraft(ScoreboardSlotArgument.getDisplaySlot(cmdCtx, key).id());
+ }
+
+ @Override
+ public Collection getScoreHolderMultiple(CommandContext cmdCtx, String key) throws CommandSyntaxException {
+ final Collection scoreHolders = ScoreHolderArgument.getNames(cmdCtx, key);
+ Set scoreHolderNames = new HashSet<>();
+ for (ScoreHolder scoreHolder : scoreHolders) {
+ scoreHolderNames.add(scoreHolder.getScoreboardName());
+ }
+ return scoreHolderNames;
+ }
+
+ @Override
+ public String getScoreHolderSingle(CommandContext cmdCtx, String key) throws CommandSyntaxException {
+ return ScoreHolderArgument.getName(cmdCtx, key).getScoreboardName();
+ }
+
+ @Override
+ public BukkitCommandSender extends CommandSender> getSenderForCommand(CommandContext cmdCtx,
+ boolean isNative) {
+ CommandSourceStack css = cmdCtx.getSource();
+
+ CommandSender sender = css.getBukkitSender();
+ if (sender == null) {
+ // Sender CANNOT be null. This can occur when using a remote console
+ // sender. You can access it directly using
+ // this.getMinecraftServer().remoteConsole
+ // however this may also be null, so delegate to the next most-meaningful
+ // sender.
+ sender = Bukkit.getConsoleSender();
+ }
+ Vec3 pos = css.getPosition();
+ Vec2 rot = css.getRotation();
+ World world = getWorldForCSS(css);
+ Location location = new Location(world, pos.x(), pos.y(), pos.z(), rot.y, rot.x);
+
+ Entity proxyEntity = css.getEntity();
+ CommandSender proxy = proxyEntity == null ? null : proxyEntity.getBukkitEntity();
+ if (isNative || (proxy != null && !sender.equals(proxy))) {
+ if (proxy == null) {
+ proxy = sender;
+ }
+
+ return new BukkitNativeProxyCommandSender(new NativeProxyCommandSender(sender, proxy, location, world));
+ } else {
+ return wrapCommandSender(sender);
+ }
+ }
+
+ @Override
+ public final SimpleCommandMap getSimpleCommandMap() {
+ return ((CraftServer) Bukkit.getServer()).getCommandMap();
+ }
+
+ @Override
+ public final Object getSound(CommandContext cmdCtx, String key, ArgumentSubType subType) {
+ final ResourceLocation soundResource = ResourceLocationArgument.getId(cmdCtx, key);
+ return switch (subType) {
+ case SOUND_SOUND -> {
+ final Optional> soundEvent = BuiltInRegistries.SOUND_EVENT.get(soundResource);
+ if (soundEvent.isEmpty()) {
+ yield null;
+ } else {
+ yield CraftSound.minecraftToBukkit(soundEvent.get().value());
+ }
+ }
+ case SOUND_NAMESPACEDKEY -> {
+ yield NamespacedKey.fromString(soundResource.getNamespace() + ":" + soundResource.getPath());
+ }
+ default -> throw new IllegalArgumentException("Unexpected value: " + subType);
+ };
+ }
+
+ @Override
+ public SuggestionProvider getSuggestionProvider(SuggestionProviders provider) {
+ return switch (provider) {
+ case FUNCTION -> (context, builder) -> {
+ ServerFunctionManager functionData = this.getMinecraftServer().getFunctions();
+ SharedSuggestionProvider.suggestResource(functionData.getTagNames(), builder, "#");
+ return SharedSuggestionProvider.suggestResource(functionData.getFunctionNames(), builder);
+ };
+ case RECIPES -> (cmdCtx, builder) -> {
+ return SharedSuggestionProvider.suggestResource(this.getMinecraftServer().getRecipeManager()
+ .getRecipes().stream().map(holder -> holder.id().location()), builder);
+ };
+ case SOUNDS -> net.minecraft.commands.synchronization.SuggestionProviders.AVAILABLE_SOUNDS;
+ case ADVANCEMENTS -> (cmdCtx, builder) -> {
+ return SharedSuggestionProvider.suggestResource(this.getMinecraftServer().getAdvancements()
+ .getAllAdvancements().stream().map(AdvancementHolder::id), builder);
+ };
+ case LOOT_TABLES -> (cmdCtx, builder) -> {
+ return SharedSuggestionProvider.suggestResource(
+ this.getMinecraftServer().reloadableRegistries().getKeys(Registries.LOOT_TABLE), builder);
+ };
+ case BIOMES -> _ArgumentSyntheticBiome()::listSuggestions;
+ case ENTITIES -> net.minecraft.commands.synchronization.SuggestionProviders.SUMMONABLE_ENTITIES;
+ default -> (context, builder) -> Suggestions.empty();
+ };
+ }
+
+ @Differs(from = "1.20.6", by = "ResourceLocation constructor change to static ResourceLocation#fromNamespaceAndPath")
+ @Override
+ public final SimpleFunctionWrapper[] getTag(NamespacedKey key) {
+ Collection> customFunctions = this.getMinecraftServer().getFunctions().getTag(ResourceLocation.fromNamespaceAndPath(key.getNamespace(), key.getKey()));
+ SimpleFunctionWrapper[] convertedCustomFunctions = new SimpleFunctionWrapper[customFunctions.size()];
+ int index = 0;
+ for (CommandFunction customFunction : customFunctions) {
+ convertedCustomFunctions[index++] = convertFunction(customFunction);
+ }
+ return convertedCustomFunctions;
+ }
+
+ @Override
+ public Set getTags() {
+ Set result = new HashSet<>();
+ for (ResourceLocation resourceLocation : this.getMinecraftServer().getFunctions().getTagNames()) {
+ result.add(fromResourceLocation(resourceLocation));
+ }
+ return result;
+ }
+
+ @Override
+ public World getWorldForCSS(CommandSourceStack css) {
+ return (css.getLevel() == null) ? null : css.getLevel().getWorld();
+ }
+
+ @Override
+ public final void reloadDataPacks() {
+ CommandAPI.logNormal("Reloading datapacks...");
+
+ // Get previously declared recipes to be re-registered later
+ Iterator recipes = Bukkit.recipeIterator();
+
+ // Update the commandDispatcher with the current server's commandDispatcher
+ ReloadableResources serverResources = this.getMinecraftServer().resources;
+ serverResources.managers().commands = this.getMinecraftServer().getCommands();
+
+ // Update the ServerFunctionLibrary's command dispatcher with the new one
+ try {
+ serverFunctionLibraryDispatcher.set(serverResources.managers().getFunctionLibrary(),
+ getBrigadierDispatcher());
+ } catch (IllegalAccessException ignored) {
+ // Shouldn't happen, CommandAPIHandler#getField makes it accessible
+ }
+
+ // From this.getMinecraftServer().reloadResources //
+ // Discover new packs
+ Collection collection;
+ {
+ List packIDs = new ArrayList<>(
+ this.getMinecraftServer().getPackRepository().getSelectedIds());
+ List disabledPacks = this.getMinecraftServer().getWorldData()
+ .getDataConfiguration().dataPacks().getDisabled();
+
+ for (String availablePack : this.getMinecraftServer().getPackRepository()
+ .getAvailableIds()) {
+ // Add every other available pack that is not disabled
+ // and is not already in the list of existing packs
+ if (!disabledPacks.contains(availablePack) && !packIDs.contains(availablePack)) {
+ packIDs.add(availablePack);
+ }
+ }
+ collection = packIDs;
+ }
+
+ // Step 1: Construct an async supplier of a list of all resource packs to
+ // be loaded in the reload phase
+ CompletableFuture> first = CompletableFuture.supplyAsync(() -> {
+ PackRepository serverPackRepository = this.getMinecraftServer().getPackRepository();
+
+ List packResources = new ArrayList<>();
+ for (String packID : collection) {
+ Pack pack = serverPackRepository.getPack(packID);
+ if (pack != null) {
+ packResources.add(pack.open());
+ }
+ }
+ return packResources;
+ });
+
+ // Step 2: Convert all of the resource packs into ReloadableResources which
+ // are replaced by our custom server resources with defined commands
+ CompletableFuture second = first.thenCompose(packResources -> {
+ MultiPackResourceManager resourceManager = new MultiPackResourceManager(PackType.SERVER_DATA,
+ packResources);
+
+ // TODO: I'm not sure if this is sufficient anymore - Do we not want to load tags for existing
+ // registries here as well?
+ // List> TagList = TagLoader.loadTagsForExistingRegistries(resourceManager, this.getMinecraftServer().registries().compositeAccess());
+
+ // Not using packResources, because we really really want this to work
+ CompletableFuture> simpleReloadInstance = SimpleReloadInstance.create(resourceManager,
+ serverResources.managers().listeners(), this.getMinecraftServer().executor,
+ this.getMinecraftServer(), CompletableFuture
+ .completedFuture(Unit.INSTANCE) /* ReloadableServerResources.DATA_RELOAD_INITIAL_TASK */,
+ LogUtils.getLogger().isDebugEnabled()).done();
+
+ return simpleReloadInstance.thenApply(x -> serverResources);
+ });
+
+ // Step 3: Actually load all of the resources
+ CompletableFuture third = second.thenAcceptAsync(resources -> {
+ this.getMinecraftServer().resources.close();
+ this.getMinecraftServer().resources = serverResources;
+ this.getMinecraftServer().server.syncCommands();
+ this.getMinecraftServer().getPackRepository().setSelected(collection);
+
+ final FeatureFlagSet enabledFeatures = this.getMinecraftServer().getWorldData().getDataConfiguration().enabledFeatures();
+
+ // this.getMinecraftServer().getSelectedPacks
+ Collection selectedIDs = this.getMinecraftServer().getPackRepository()
+ .getSelectedIds();
+ List enabledIDs = ImmutableList.copyOf(selectedIDs);
+ List disabledIDs = new ArrayList<>(
+ this.getMinecraftServer().getPackRepository().getAvailableIds());
+
+ disabledIDs.removeIf(enabledIDs::contains);
+
+ this.getMinecraftServer().getWorldData()
+ .setDataConfiguration(new WorldDataConfiguration(new DataPackConfig(enabledIDs, disabledIDs), enabledFeatures));
+ // this.getMinecraftServer().resources.managers().updateRegistryTags(registryAccess);
+ this.getMinecraftServer().resources.managers().updateStaticRegistryTags(); // TODO: Review this
+ this.getMinecraftServer().resources.managers().getRecipeManager().finalizeRecipeLoading(enabledFeatures);
+
+ // May need to be commented out, may not. Comment it out just in case.
+ // For some reason, calling getPlayerList().saveAll() may just hang
+ // the server indefinitely. Not sure why!
+ // this.getMinecraftServer().getPlayerList().saveAll();
+ // this.getMinecraftServer().getPlayerList().reloadResources();
+ // this.getMinecraftServer().getFunctions().replaceLibrary(this.getMinecraftServer().resources.managers().getFunctionLibrary());
+ this.getMinecraftServer().getStructureManager()
+ .onResourceManagerReload(this.getMinecraftServer().resources.resourceManager());
+
+ // Set fuel values with the new loaded fuel values from the list of enabled features
+ minecraftServerFuelValues.set(this.getMinecraftServer(),
+ FuelValues.vanillaBurnTimes(this.getMinecraftServer().registries().compositeAccess(),
+ enabledFeatures
+ )
+ );
+ });
+
+ // Step 4: Block the thread until everything's done
+ if (this.getMinecraftServer().isSameThread()) {
+ this.getMinecraftServer().managedBlock(third::isDone);
+ }
+
+ // Run the completableFuture (and bind tags?)
+ try {
+
+ // Register recipes again because reloading datapacks
+ // removes all non-vanilla recipes
+ registerBukkitRecipesSafely(recipes);
+
+ CommandAPI.logNormal("Finished reloading datapacks");
+ } catch (Exception e) {
+ StringWriter stringWriter = new StringWriter();
+ PrintWriter printWriter = new PrintWriter(stringWriter);
+ e.printStackTrace(printWriter);
+
+ CommandAPI.logError(
+ "Failed to load datapacks, can't proceed with normal server load procedure. Try fixing your datapacks?\n"
+ + stringWriter.toString());
+ }
+ }
+
+ @Override
+ public Message generateMessageFromJson(String json) {
+ // TODO: Same as #getAdventureChatComponent, figure out if an empty provider is suitable here
+ return Serializer.fromJson(json, COMMAND_BUILD_CONTEXT);
+ }
+
+ @SuppressWarnings("unchecked")
+ @Override
+ public T getMinecraftServer() {
+ if (Bukkit.getServer() instanceof CraftServer server) {
+ return (T) server.getServer();
+ } else {
+ return null;
+ }
+ }
+
+ @Override
+ public ArgumentType> _ArgumentMobEffect() {
+ return ResourceArgument.resource(COMMAND_BUILD_CONTEXT, Registries.MOB_EFFECT);
+ }
+
+ @Override
+ public ArgumentType> _ArgumentEntitySummon() {
+ return ResourceArgument.resource(COMMAND_BUILD_CONTEXT, Registries.ENTITY_TYPE);
+ }
+
+ @Override
+ public CommandRegistrationStrategy createCommandRegistrationStrategy() {
+ if (vanillaCommandDispatcherFieldExists) {
+ return new SpigotCommandRegistration<>(
+ this.getMinecraftServer().vanillaCommandDispatcher.getDispatcher(),
+ (SimpleCommandMap) getPaper().getCommandMap(),
+ () -> this.getMinecraftServer().getCommands().getDispatcher(),
+ command -> command instanceof VanillaCommandWrapper,
+ node -> new VanillaCommandWrapper(this.getMinecraftServer().vanillaCommandDispatcher, node),
+ node -> node.getCommand() instanceof BukkitCommandWrapper
+ );
+ } else {
+ // This class is Paper-server specific, so we need to use paper's userdev plugin to
+ // access it directly. That might need gradle, but there might also be a maven version?
+ // https://discord.com/channels/289587909051416579/1121227200277004398/1246910745761812480
+ Class> bukkitCommandNode_bukkitBrigCommand;
+ try {
+ bukkitCommandNode_bukkitBrigCommand = Class.forName("io.papermc.paper.command.brigadier.bukkit.BukkitCommandNode$BukkitBrigCommand");
+ } catch (ClassNotFoundException e) {
+ throw new IllegalStateException("Expected to find class", e);
+ }
+ return new PaperCommandRegistration<>(
+ () -> this.getMinecraftServer().getCommands().getDispatcher(),
+ node -> bukkitCommandNode_bukkitBrigCommand.isInstance(node.getCommand())
+ );
+ }
+ }
+}
diff --git a/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-nms/commandapi-bukkit-1.21.4/src/test/java/SafeReflect.java b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-nms/commandapi-bukkit-1.21.4/src/test/java/SafeReflect.java
new file mode 100644
index 0000000000..6ac91a8d91
--- /dev/null
+++ b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-nms/commandapi-bukkit-1.21.4/src/test/java/SafeReflect.java
@@ -0,0 +1,41 @@
+/*******************************************************************************
+ * Copyright 2024 Jorel Ali (Skepter) - MIT License
+ *
+ * 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.
+ *******************************************************************************/
+import java.util.Map;
+
+import org.bukkit.craftbukkit.v1_21_R3.help.SimpleHelpMap;
+
+import com.mojang.brigadier.CommandDispatcher;
+import com.mojang.datafixers.util.Either;
+
+import dev.jorel.commandapi.preprocessor.RequireField;
+import net.minecraft.commands.arguments.selector.EntitySelector;
+import net.minecraft.server.CustomFunctionManager;
+import net.minecraft.server.MinecraftServer;
+import net.minecraft.world.level.block.entity.FuelValues;
+import net.minecraft.world.level.gameevent.EntityPositionSource;
+
+// Spigot-Mapped reflection
+@RequireField(in = CustomFunctionManager.class, name = "h", ofType = CommandDispatcher.class)
+@RequireField(in = EntitySelector.class, name = "p", ofType = boolean.class)
+@RequireField(in = SimpleHelpMap.class, name = "helpTopics", ofType = Map.class)
+@RequireField(in = EntityPositionSource.class, name = "e", ofType = Either.class)
+@RequireField(in = MinecraftServer.class, name = "aE", ofType = FuelValues.class)
+public class SafeReflect {}
\ No newline at end of file
diff --git a/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-nms/commandapi-bukkit-1.21/pom.xml b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-nms/commandapi-bukkit-1.21/pom.xml
index 0fe5ad4687..7d61055702 100644
--- a/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-nms/commandapi-bukkit-1.21/pom.xml
+++ b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-nms/commandapi-bukkit-1.21/pom.xml
@@ -21,7 +21,7 @@
commandapi-bukkit-nms
dev.jorel
- 9.6.1
+ 9.7.0
4.0.0
diff --git a/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-nms/commandapi-bukkit-nms-common/pom.xml b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-nms/commandapi-bukkit-nms-common/pom.xml
index e6857ab4df..bd7182e459 100644
--- a/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-nms/commandapi-bukkit-nms-common/pom.xml
+++ b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-nms/commandapi-bukkit-nms-common/pom.xml
@@ -19,7 +19,7 @@
commandapi-bukkit-nms
dev.jorel
- 9.6.1
+ 9.7.0
4.0.0
diff --git a/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-nms/commandapi-bukkit-nms-dependency-mojang-mapped/pom.xml b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-nms/commandapi-bukkit-nms-dependency-mojang-mapped/pom.xml
index 7484715589..85b75488d6 100644
--- a/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-nms/commandapi-bukkit-nms-dependency-mojang-mapped/pom.xml
+++ b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-nms/commandapi-bukkit-nms-dependency-mojang-mapped/pom.xml
@@ -19,7 +19,7 @@
commandapi-bukkit-nms
dev.jorel
- 9.6.1
+ 9.7.0
4.0.0
@@ -48,6 +48,12 @@
+
+ dev.jorel
+ commandapi-bukkit-1.21.4
+ ${project.version}
+ mojang-mapped
+
dev.jorel
commandapi-bukkit-1.21.2
diff --git a/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-nms/commandapi-bukkit-nms-dependency/pom.xml b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-nms/commandapi-bukkit-nms-dependency/pom.xml
index 829a25a466..e1ca1d6963 100644
--- a/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-nms/commandapi-bukkit-nms-dependency/pom.xml
+++ b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-nms/commandapi-bukkit-nms-dependency/pom.xml
@@ -19,7 +19,7 @@
commandapi-bukkit-nms
dev.jorel
- 9.6.1
+ 9.7.0
4.0.0
@@ -45,6 +45,11 @@
+
+ dev.jorel
+ commandapi-bukkit-1.21.4
+ ${project.version}
+
dev.jorel
commandapi-bukkit-1.21.2
diff --git a/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-nms/pom.xml b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-nms/pom.xml
index acb749639a..a0524a5b1a 100644
--- a/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-nms/pom.xml
+++ b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-nms/pom.xml
@@ -26,7 +26,7 @@
commandapi-bukkit
dev.jorel
- 9.6.1
+ 9.7.0
commandapi-bukkit-nms
@@ -35,6 +35,7 @@
commandapi-bukkit-nms-common
+ commandapi-bukkit-1.21.4
commandapi-bukkit-1.21.2
commandapi-bukkit-1.21
commandapi-bukkit-1.20.5
diff --git a/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-plugin-common/pom.xml b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-plugin-common/pom.xml
index eb7703a4d6..5804aedaa8 100644
--- a/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-plugin-common/pom.xml
+++ b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-plugin-common/pom.xml
@@ -7,7 +7,7 @@
dev.jorel
commandapi-bukkit
- 9.6.1
+ 9.7.0
commandapi-bukkit-plugin-common
diff --git a/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-plugin-mojang-mapped/pom.xml b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-plugin-mojang-mapped/pom.xml
index 6dacebd4be..f6f17104be 100644
--- a/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-plugin-mojang-mapped/pom.xml
+++ b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-plugin-mojang-mapped/pom.xml
@@ -25,7 +25,7 @@
commandapi-bukkit
dev.jorel
- 9.6.1
+ 9.7.0
4.0.0
@@ -134,6 +134,10 @@
org.bukkit.craftbukkit.v1_21_R2
org.bukkit.craftbukkit
+
+ org.bukkit.craftbukkit.v1_21_R3
+ org.bukkit.craftbukkit
+
diff --git a/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-plugin/pom.xml b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-plugin/pom.xml
index 3603f5bc37..56fe70faaa 100644
--- a/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-plugin/pom.xml
+++ b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-plugin/pom.xml
@@ -25,7 +25,7 @@
commandapi-bukkit
dev.jorel
- 9.6.1
+ 9.7.0
4.0.0
diff --git a/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-shade-mojang-mapped/pom.xml b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-shade-mojang-mapped/pom.xml
index 2602e07b3d..ce154069ba 100644
--- a/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-shade-mojang-mapped/pom.xml
+++ b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-shade-mojang-mapped/pom.xml
@@ -25,7 +25,7 @@
commandapi-bukkit
dev.jorel
- 9.6.1
+ 9.7.0
4.0.0
@@ -96,6 +96,10 @@
org.bukkit.craftbukkit.v1_21_R2
org.bukkit.craftbukkit
+
+ org.bukkit.craftbukkit.v1_21_R3
+ org.bukkit.craftbukkit
+
diff --git a/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-shade/pom.xml b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-shade/pom.xml
index f0cd3bf364..d3f6393512 100644
--- a/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-shade/pom.xml
+++ b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-shade/pom.xml
@@ -25,7 +25,7 @@
commandapi-bukkit
dev.jorel
- 9.6.1
+ 9.7.0
4.0.0
diff --git a/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-test-toolkit/pom.xml b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-test-toolkit/pom.xml
new file mode 100644
index 0000000000..6c7840a77f
--- /dev/null
+++ b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-test-toolkit/pom.xml
@@ -0,0 +1,168 @@
+
+
+ 4.0.0
+
+ dev.jorel
+ commandapi-bukkit
+ 9.7.0
+
+
+ commandapi-bukkit-test-toolkit
+
+
+ 21
+ 21
+ UTF-8
+
+
+
+
+ minecraft-libraries
+ Minecraft Libraries
+ https://libraries.minecraft.net
+
+
+ papermc
+ https://repo.papermc.io/repository/maven-public/
+
+
+
+
+
+
+
+ com.github.seeseemelk
+ MockBukkit-v1.21
+ 3.133.2
+ provided
+
+
+
+
+ dev.jorel
+ commandapi-bukkit-core
+ ${project.version}
+ provided
+
+
+
+
+ org.mockito
+ mockito-core
+ 5.11.0
+ compile
+
+
+
+
+ com.mojang
+ brigadier
+ 1.0.18
+ compile
+
+
+
+
+
+
+ org.slf4j
+ slf4j-nop
+ 2.0.9
+ compile
+
+
+
+
+
+ org.junit.jupiter
+ junit-jupiter-engine
+ 5.11.0
+ test
+
+
+
+
+
+
+
+ src/main/resources
+ true
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-shade-plugin
+
+
+ package
+
+ shade
+
+
+
+
+ dev.jorel:commandapi-core
+
+ dev/jorel/commandapi/CommandAPIVersionHandler**
+
+
+
+
+
+ LICENSE
+
+
+
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+ 3.3.0
+
+ false
+
+
+
+
+
+
+
+
+
+ org.jacoco
+ jacoco-maven-plugin
+
+
+
+ default-prepare-agent
+
+ prepare-agent
+
+
+
+
+ report
+ verify
+
+ report
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-test-toolkit/src/main/java/dev/jorel/commandapi/CommandAPITestUtilities.java b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-test-toolkit/src/main/java/dev/jorel/commandapi/CommandAPITestUtilities.java
new file mode 100644
index 0000000000..e08f0708fb
--- /dev/null
+++ b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-test-toolkit/src/main/java/dev/jorel/commandapi/CommandAPITestUtilities.java
@@ -0,0 +1,481 @@
+package dev.jorel.commandapi;
+
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
+import com.mojang.brigadier.CommandDispatcher;
+import com.mojang.brigadier.Message;
+import com.mojang.brigadier.ParseResults;
+import com.mojang.brigadier.context.StringRange;
+import com.mojang.brigadier.exceptions.CommandSyntaxException;
+import com.mojang.brigadier.suggestion.Suggestion;
+import com.mojang.brigadier.suggestion.Suggestions;
+import dev.jorel.commandapi.commandsenders.AbstractCommandSender;
+import dev.jorel.commandapi.executors.CommandArguments;
+import dev.jorel.commandapi.executors.ExecutionInfo;
+import dev.jorel.commandapi.spying.ExecutionQueue;
+import org.bukkit.command.CommandSender;
+import org.opentest4j.AssertionFailedError;
+
+import java.util.*;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Provides static methods to interact with CommandAPI commands registered in the test environment. This includes
+ * verifying that a command runs successfully, fails with a certain error message, runs with certain arguments, or
+ * suggests strings or tooltips.
+ *
+ * Tip: The standard way to access static methods is by using the class name (e.g.
+ * {@code CommandAPITestUtilities.assertCommandSucceeds(...)}). However, you can also call these methods with just the
+ * method name ({@code assertCommandSucceeds(...)}), if you add a static import
+ * ({@code import static dev.jorel.commandapi.CommandAPITestUtilities.assertCommandSucceeds;}) or make your test class
+ * extend this class ({@code class Tests extends CommandAPITestUtilities {...}}).
+ */
+// In places where Assertions methods are called, the Supplier option is used
+// so that the coverage report shows uncovered code if the tests do not check the scenario
+// where the assertion fails.
+@SuppressWarnings("ExcessiveLambdaUsage")
+public class CommandAPITestUtilities {
+ ///////////////////////
+ // General utilities //
+ ///////////////////////
+
+ /**
+ * @return The {@link MockCommandAPIBukkit} platform class currently loaded.
+ */
+ public static MockCommandAPIBukkit getCommandAPIPlatform() {
+ return MockCommandAPIBukkit.getInstance();
+ }
+
+ //////////////////////
+ // Running commands //
+ //////////////////////
+
+ /**
+ * Attempts to run a CommandAPI command.
+ *
+ * @param sender The {@link CommandSender} running the command.
+ * @param command The command to execute.
+ * @throws CommandSyntaxException If the command fails for any reason.
+ * @see #assertCommandSucceeds(CommandSender, String)
+ * @see #assertCommandFails(CommandSender, String, String)
+ */
+ public static void dispatchCommand(CommandSender sender, String command) throws CommandSyntaxException {
+ getCommandAPIPlatform().getBrigadierDispatcher().execute(command, new MockCommandSource(sender));
+ }
+
+ /**
+ * Runs a CommandAPI command and asserts that it succeeds.
+ *
+ * @param sender The {@link CommandSender} running the command.
+ * @param command The command to execute.
+ * @throws AssertionFailedError If dispatching the command fails for any reason.
+ * @see #dispatchCommand(CommandSender, String)
+ */
+ public static void assertCommandSucceeds(CommandSender sender, String command) {
+ assertDoesNotThrow(
+ () -> dispatchCommand(sender, command),
+ () -> "Expected command dispatch to succeed"
+ );
+ }
+
+ /**
+ * Runs a CommandAPI command as asserts that it fails.
+ *
+ * @param sender The {@link CommandSender} running the command.
+ * @param command The command to execute.
+ * @param expectedMessage The expected message thrown when the command fails.
+ * @return The {@link CommandSyntaxException} that resulted when the command failed.
+ * This can be ignored if no further checks are necessary.
+ * @throws AssertionFailedError If dispatching the command does not fail,
+ * or it fails with a different message than expected.
+ * @see #dispatchCommand(CommandSender, String)
+ */
+ @CanIgnoreReturnValue
+ public static CommandSyntaxException assertCommandFails(CommandSender sender, String command, String expectedMessage) {
+ CommandSyntaxException exception = assertThrows(
+ CommandSyntaxException.class,
+ () -> dispatchCommand(sender, command),
+ () -> "Expected command dispatch to fail"
+ );
+
+ String actualMessage = exception.getMessage();
+ if (!Objects.equals(expectedMessage, actualMessage)) {
+ throw new AssertionFailedError(
+ "Expected command dispatch to fail with message <" + expectedMessage + ">, but got <" + actualMessage + ">"
+ );
+ }
+ return exception;
+ }
+
+ /////////////////////////
+ // Verifying arguments //
+ /////////////////////////
+ private static ExecutionInfo>
+ getExecutionInfo(Runnable executeCommand) {
+ ExecutionQueue executions = getCommandAPIPlatform().getCommandAPIHandlerSpy().getExecutionQueue();
+ executions.clear();
+
+ executeCommand.run();
+
+ ExecutionInfo> execution = executions.poll();
+ assertNotNull(execution, () -> "No CommandAPI executor was invoked");
+ executions.assertNoMoreCommandsWereRun();
+
+ return execution;
+ }
+
+ /**
+ * Returns the {@link ExecutionInfo} used when a CommandAPI command was executed successfully.
+ *
+ * @param sender The {@link CommandSender} running the command.
+ * @param command The command to execute.
+ * @return The {@link ExecutionInfo} used when the command was executed.
+ * @throws AssertionFailedError If the command does not succeed or a CommandAPI executor is not invoked.
+ * @see #assertCommandSucceedsWithArguments(CommandSender, String, Object...)
+ * @see #assertCommandSucceedsWithArguments(CommandSender, String, Map)
+ * @see #assertCommandSucceeds(CommandSender, String)
+ * @see #getExecutionInfoOfFailingCommand(CommandSender, String, String)
+ */
+ public static ExecutionInfo>
+ getExecutionInfoOfSuccessfulCommand(CommandSender sender, String command) {
+ return getExecutionInfo(() -> assertCommandSucceeds(sender, command));
+ }
+
+ /**
+ * Asserts that the given array of argument objects was available when a command executed successfully.
+ *
+ * @param sender The {@link CommandSender} running the command.
+ * @param command The command to execute.
+ * @param argumentsArray The {@link CommandArguments#args()} that is expected when the command executes.
+ * @throws AssertionFailedError If the command does not succeed or the available arguments array is different from expected.
+ * @see #assertCommandSucceedsWithArguments(CommandSender, String, Map)
+ * @see #assertCommandSucceeds(CommandSender, String)
+ * @see #getExecutionInfoOfSuccessfulCommand(CommandSender, String)
+ */
+ public static void assertCommandSucceedsWithArguments(CommandSender sender, String command, Object... argumentsArray) {
+ assertArrayEquals(argumentsArray, getExecutionInfoOfSuccessfulCommand(sender, command).args().args(),
+ () -> "Argument arrays are not equal"
+ );
+ }
+
+ /**
+ * Asserts that the given map of argument objects was available when a command executed successfully.
+ *
+ * @param sender The {@link CommandSender} running the command.
+ * @param command The command to execute.
+ * @param argumentsMap The {@link CommandArguments#argsMap()} that is expected when the command executes.
+ * @throws AssertionFailedError If the command does not succeed or the available arguments map is different from expected.
+ * @see #assertCommandSucceedsWithArguments(CommandSender, String, Object...)
+ * @see #assertCommandSucceeds(CommandSender, String)
+ * @see #getExecutionInfoOfSuccessfulCommand(CommandSender, String)
+ */
+ public static void assertCommandSucceedsWithArguments(CommandSender sender, String command, Map argumentsMap) {
+ assertEquals(argumentsMap, getExecutionInfoOfSuccessfulCommand(sender, command).args().argsMap(),
+ () -> "Argument maps are not equal"
+ );
+ }
+
+ /**
+ * Returns the {@link ExecutionInfo} used when a CommandAPI command was executed and threw an exception.
+ * Note that if the command dispatch fails because the input was not parsed properly, then a CommandAPI
+ * executor will not be invoked, and this assertion will fail.
+ *
+ * @param sender The {@link CommandSender} running the command.
+ * @param command The command to execute.
+ * @param expectedFailureMessage The expected message thrown when the command fails.
+ * @return The {@link ExecutionInfo} used when the command was executed.
+ * @throws AssertionFailedError If dispatching the command does not fail, it fails with a different message than
+ * expected, or a CommandAPI executor is not invoked.
+ * @see #assertCommandFailsWithArguments(CommandSender, String, String, Object...)
+ * @see #assertCommandFailsWithArguments(CommandSender, String, String, Map)
+ * @see #assertCommandFails(CommandSender, String, String)
+ * @see #getExecutionInfoOfSuccessfulCommand(CommandSender, String)
+ */
+ public static ExecutionInfo>
+ getExecutionInfoOfFailingCommand(CommandSender sender, String command, String expectedFailureMessage) {
+ return getExecutionInfo(() -> assertCommandFails(sender, command, expectedFailureMessage));
+ }
+
+ /**
+ * Asserts that the given array of argument objects was available when a command executed and threw an exception.
+ * Note that if the command dispatch fails because the input was not parsed properly, then a CommandAPI
+ * executor will not be invoked, and this assertion will fail.
+ *
+ * @param sender The {@link CommandSender} running the command.
+ * @param command The command to execute.
+ * @param expectedFailureMessage The expected message thrown when the command fails.
+ * @param argumentsArray The {@link CommandArguments#args()} that is expected when the command executes.
+ * @throws AssertionFailedError If the command does not fail or the available arguments array is different from expected.
+ * @see #assertCommandFailsWithArguments(CommandSender, String, String, Map)
+ * @see #assertCommandFails(CommandSender, String, String)
+ * @see #getExecutionInfoOfFailingCommand(CommandSender, String, String)
+ */
+ public static void assertCommandFailsWithArguments(
+ CommandSender sender, String command, String expectedFailureMessage,
+ Object... argumentsArray
+ ) {
+ assertArrayEquals(argumentsArray, getExecutionInfoOfFailingCommand(sender, command, expectedFailureMessage).args().args(),
+ () -> "Argument arrays are not equal"
+ );
+ }
+
+ /**
+ * Asserts that the given map of argument objects was available when a command executed successfully.
+ *
+ * @param sender The {@link CommandSender} running the command.
+ * @param command The command to execute.
+ * @param expectedFailureMessage The expected message thrown when the command fails.
+ * @param argumentsMap The {@link CommandArguments#argsMap()} that is expected when the command executes.
+ * @throws AssertionFailedError If the command does not fail or the available arguments map is different from expected.
+ * @see #assertCommandFailsWithArguments(CommandSender, String, String, Object...)
+ * @see #assertCommandFails(CommandSender, String, String)
+ * @see #getExecutionInfoOfFailingCommand(CommandSender, String, String)
+ */
+ public static void assertCommandFailsWithArguments(
+ CommandSender sender, String command, String expectedFailureMessage,
+ Map argumentsMap
+ ) {
+ assertEquals(argumentsMap, getExecutionInfoOfFailingCommand(sender, command, expectedFailureMessage).args().argsMap(),
+ () -> "Argument maps are not equal"
+ );
+ }
+
+ ///////////////////////////
+ // Verifying suggestions //
+ ///////////////////////////
+ // Helper methods
+
+ /**
+ * Returns the Brigadier {@link Suggestions} provided for the given command.
+ *
+ * @param sender The {@link CommandSender} requesting suggestions.
+ * @param command The command to complete.
+ * @return The {@link Suggestions} provided for this command.
+ * @see #assertCommandSuggests(CommandSender, String, String...)
+ * @see #assertCommandSuggests(CommandSender, String, int, String...)
+ * @see #assertCommandSuggestsTooltips(CommandSender, String, Suggestion...)
+ * @see #assertCommandSuggestsTooltips(CommandSender, String, int, Suggestion...)
+ */
+ public static Suggestions getSuggestions(CommandSender sender, String command) {
+ CommandDispatcher dispatcher = getCommandAPIPlatform().getBrigadierDispatcher();
+ ParseResults parse = dispatcher.parse(command, new MockCommandSource(sender));
+ return dispatcher.getCompletionSuggestions(parse).join();
+ }
+
+ /**
+ * Asserts that all {@link Suggestion}s in the given list start at the given index.
+ *
+ * @param startingAt The index that the suggestions should start at.
+ * @param suggestions A {@link List} of {@link Suggestion}s to check.
+ * @throws AssertionFailedError If any of the given {@link Suggestion}s do not start at the given index.
+ * @see #assertCommandSuggests(CommandSender, String, int, String...)
+ * @see #assertCommandSuggestsTooltips(CommandSender, String, int, Suggestion...)
+ */
+ public static void assertSuggestionsStartAt(int startingAt, List suggestions) {
+ for (int i = 0; i < suggestions.size(); i++) {
+ Suggestion suggestion = suggestions.get(i);
+
+ int finalI = i;
+ assertEquals(startingAt, suggestion.getRange().getStart(),
+ () -> "Suggestion #" + finalI + " <" + suggestion + "> started at wrong index"
+ );
+ }
+ }
+
+ /**
+ * Asserts that the {@link Suggestion#getText()} of each given {@link Suggestion} matches an expected {@link String}.
+ *
+ * @param expectedSuggestions A {@link List} of {@link String}s that are expected to be suggested.
+ * @param actualSuggestions The {@link List} of {@link Suggestion}s that was actually produced.
+ * @throws AssertionFailedError If any of the actual {@link Suggestion}s do not have the expected text.
+ * @see #assertCommandSuggests(CommandSender, String, String...)
+ * @see #assertCommandSuggests(CommandSender, String, int, String...)
+ */
+ public static void assertSuggestionEquality(List expectedSuggestions, List actualSuggestions) {
+ List actualSuggestionStrings = new ArrayList<>(actualSuggestions.size());
+ actualSuggestions.forEach(suggestion -> actualSuggestionStrings.add(suggestion.getText()));
+
+ assertEquals(expectedSuggestions, actualSuggestionStrings, () -> "Suggestions did not match");
+ }
+
+ /**
+ * Asserts that the {@link Suggestion#getText()} and {@link Suggestion#getTooltip()} of each given
+ * {@link Suggestion} matches an expected {@link Suggestion}.
+ *
+ * @param expectedSuggestions A {@link List} of {@link Suggestion}s that are expected to be suggested.
+ * @param actualSuggestions The {@link List} of {@link Suggestion}s that was actually produced.
+ * @throws AssertionFailedError If any of the actual {@link Suggestion}s do not have the expected text or tooltip.
+ * @see #makeTooltip(String, String)
+ * @see #assertCommandSuggestsTooltips(CommandSender, String, Suggestion...)
+ * @see #assertCommandSuggestsTooltips(CommandSender, String, int, Suggestion...)
+ */
+ public static void assertSuggestionEqualityTooltips(List expectedSuggestions, List actualSuggestions) {
+ List actualSuggestionStrings = new ArrayList<>(actualSuggestions.size());
+ List actualSuggestionTooltips = new ArrayList<>(actualSuggestions.size());
+ actualSuggestions.forEach(suggestion -> {
+ actualSuggestionStrings.add(suggestion.getText());
+
+ Message tooltip = suggestion.getTooltip();
+ actualSuggestionTooltips.add(tooltip == null ? null : tooltip.getString());
+ });
+
+ List expectedSuggestionStrings = new ArrayList<>(expectedSuggestions.size());
+ List expectedSuggestionTooltips = new ArrayList<>(expectedSuggestions.size());
+ expectedSuggestions.forEach(suggestion -> {
+ expectedSuggestionStrings.add(suggestion.getText());
+
+ Message tooltip = suggestion.getTooltip();
+ expectedSuggestionTooltips.add(tooltip == null ? null : tooltip.getString());
+ });
+
+ assertEquals(expectedSuggestionStrings, actualSuggestionStrings, () -> "Suggestions did not match");
+ assertEquals(expectedSuggestionTooltips, actualSuggestionTooltips, () -> "Tooltips did not match");
+ }
+
+ // Public assertions
+
+ /**
+ * Asserts that the suggestions provided for the given command have the given texts.
+ *
+ * @param sender The {@link CommandSender} requesting suggestions.
+ * @param command The command to complete.
+ * @param expectedSuggestions The suggestion texts that are expected.
+ * @throws AssertionFailedError If any of the actual suggestions do not have the expected text.
+ * @see #getSuggestions(CommandSender, String)
+ * @see #assertCommandSuggests(CommandSender, String, int, String...)
+ * @see #assertCommandSuggests(CommandSender, String, List)
+ */
+ public static void assertCommandSuggests(CommandSender sender, String command, String... expectedSuggestions) {
+ assertCommandSuggests(sender, command, Arrays.asList(expectedSuggestions));
+ }
+
+ /**
+ * Asserts that the suggestions provided for the given command start at the given index and have the given texts.
+ *
+ * @param sender The {@link CommandSender} requesting suggestions.
+ * @param command The command to complete.
+ * @param startingAt The location in the command where the suggestions are expected.
+ * @param expectedSuggestions The suggestion texts that are expected.
+ * @throws AssertionFailedError If any of the actual suggestions do not start or the given index or have the expected text.
+ * @see #getSuggestions(CommandSender, String)
+ * @see #assertCommandSuggests(CommandSender, String, String...)
+ * @see #assertCommandSuggests(CommandSender, String, int, List)
+ */
+ public static void assertCommandSuggests(CommandSender sender, String command, int startingAt, String... expectedSuggestions) {
+ assertCommandSuggests(sender, command, startingAt, Arrays.asList(expectedSuggestions));
+ }
+
+ /**
+ * Asserts that the suggestions provided for the given command have the given texts.
+ *
+ * @param sender The {@link CommandSender} requesting suggestions.
+ * @param command The command to complete.
+ * @param expectedSuggestions The suggestion texts that are expected.
+ * @throws AssertionFailedError If any of the actual suggestions do not have the expected text.
+ * @see #getSuggestions(CommandSender, String)
+ * @see #assertCommandSuggests(CommandSender, String, int, List)
+ * @see #assertCommandSuggests(CommandSender, String, String...)
+ */
+ public static void assertCommandSuggests(CommandSender sender, String command, List expectedSuggestions) {
+ assertSuggestionEquality(expectedSuggestions, getSuggestions(sender, command).getList());
+ }
+
+ /**
+ * Asserts that the suggestions provided for the given command start at the given index and have the given texts.
+ *
+ * @param sender The {@link CommandSender} requesting suggestions.
+ * @param command The command to complete.
+ * @param startingAt The location in the command where the suggestions are expected.
+ * @param expectedSuggestions The suggestion texts that are expected.
+ * @throws AssertionFailedError If any of the actual suggestions do not start or the given index or have the expected text.
+ * @see #getSuggestions(CommandSender, String)
+ * @see #assertCommandSuggests(CommandSender, String, String...)
+ * @see #assertCommandSuggests(CommandSender, String, int, List)
+ */
+ public static void assertCommandSuggests(CommandSender sender, String command, int startingAt, List expectedSuggestions) {
+ List actualSuggestions = getSuggestions(sender, command).getList();
+ assertSuggestionsStartAt(startingAt, actualSuggestions);
+ assertSuggestionEquality(expectedSuggestions, actualSuggestions);
+ }
+
+ /**
+ * Creates a {@link Suggestion} with the given {@link String}s as its text and tooltip.
+ *
+ * @param text The {@link String} for {@link Suggestion#getText()}.
+ * @param tooltip The {@link String} for {@link Suggestion#getTooltip()}. This may be null to indicate no tooltip.
+ * @return A new {@link Suggestion}.
+ * @see #assertCommandSuggestsTooltips(CommandSender, String, Suggestion...)
+ * @see #assertCommandSuggestsTooltips(CommandSender, String, int, Suggestion...)
+ */
+ public static Suggestion makeTooltip(String text, String tooltip) {
+ return new Suggestion(StringRange.at(0), text, tooltip == null ? null : () -> tooltip);
+ }
+
+ /**
+ * Asserts that the suggestions provided for the given command have the given texts and tooltips.
+ *
+ * @param sender The {@link CommandSender} requesting suggestions.
+ * @param command The command to complete.
+ * @param expectedSuggestions The suggestions that are expected.
+ * @throws AssertionFailedError If any of the actual suggestions do not have the expected text or tooltip.
+ * @see #makeTooltip(String, String)
+ * @see #getSuggestions(CommandSender, String)
+ * @see #assertCommandSuggestsTooltips(CommandSender, String, int, Suggestion...)
+ * @see #assertCommandSuggestsTooltips(CommandSender, String, List)
+ */
+ public static void assertCommandSuggestsTooltips(CommandSender sender, String command, Suggestion... expectedSuggestions) {
+ assertCommandSuggestsTooltips(sender, command, Arrays.asList(expectedSuggestions));
+ }
+
+ /**
+ * Asserts that the suggestions provided for the given command start at the given index and have the given texts and tooltips.
+ *
+ * @param sender The {@link CommandSender} requesting suggestions.
+ * @param command The command to complete.
+ * @param startingAt The location in the command where the suggestions are expected.
+ * @param expectedSuggestions The suggestions that are expected.
+ * @throws AssertionFailedError If any of the actual suggestions do not start or the given index or have the expected text or tooltip.
+ * @see #makeTooltip(String, String)
+ * @see #getSuggestions(CommandSender, String)
+ * @see #assertCommandSuggestsTooltips(CommandSender, String, Suggestion...)
+ * @see #assertCommandSuggestsTooltips(CommandSender, String, int, List)
+ */
+ public static void assertCommandSuggestsTooltips(CommandSender sender, String command, int startingAt, Suggestion... expectedSuggestions) {
+ assertCommandSuggestsTooltips(sender, command, startingAt, Arrays.asList(expectedSuggestions));
+ }
+
+ /**
+ * Asserts that the suggestions provided for the given command have the given texts and tooltips.
+ *
+ * @param sender The {@link CommandSender} requesting suggestions.
+ * @param command The command to complete.
+ * @param expectedSuggestions The suggestions that are expected.
+ * @throws AssertionFailedError If any of the actual suggestions do not have the expected text or tooltip.
+ * @see #makeTooltip(String, String)
+ * @see #getSuggestions(CommandSender, String)
+ * @see #assertCommandSuggestsTooltips(CommandSender, String, int, List)
+ * @see #assertCommandSuggestsTooltips(CommandSender, String, Suggestion...)
+ */
+ public static void assertCommandSuggestsTooltips(CommandSender sender, String command, List expectedSuggestions) {
+ assertSuggestionEqualityTooltips(expectedSuggestions, getSuggestions(sender, command).getList());
+ }
+
+ /**
+ * Asserts that the suggestions provided for the given command start at the given index and have the given texts and tooltips.
+ *
+ * @param sender The {@link CommandSender} requesting suggestions.
+ * @param command The command to complete.
+ * @param startingAt The location in the command where the suggestions are expected.
+ * @param expectedSuggestions The suggestions that are expected.
+ * @throws AssertionFailedError If any of the actual suggestions do not start or the given index or have the expected text or tooltip.
+ * @see #makeTooltip(String, String)
+ * @see #getSuggestions(CommandSender, String)
+ * @see #assertCommandSuggestsTooltips(CommandSender, String, List)
+ * @see #assertCommandSuggestsTooltips(CommandSender, String, int, Suggestion...)
+ */
+ public static void assertCommandSuggestsTooltips(CommandSender sender, String command, int startingAt, List expectedSuggestions) {
+ List actualSuggestions = getSuggestions(sender, command).getList();
+ assertSuggestionsStartAt(startingAt, actualSuggestions);
+ assertSuggestionEqualityTooltips(expectedSuggestions, actualSuggestions);
+ }
+}
diff --git a/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-test-toolkit/src/main/java/dev/jorel/commandapi/CommandAPIVersionHandler.java b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-test-toolkit/src/main/java/dev/jorel/commandapi/CommandAPIVersionHandler.java
new file mode 100644
index 0000000000..b3a18cce01
--- /dev/null
+++ b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-test-toolkit/src/main/java/dev/jorel/commandapi/CommandAPIVersionHandler.java
@@ -0,0 +1,27 @@
+package dev.jorel.commandapi;
+
+public abstract class CommandAPIVersionHandler {
+ // Allow loading a different platform implementation (most likely to implement something `MockCommandAPIBukkit` doesn't)
+ private static CommandAPIPlatform, ?, ?> alternativePlatform = null;
+
+ /**
+ * Configures the test kit to use the given {@link CommandAPIPlatform} when the CommandAPI is loaded.
+ *
+ * @param platform The {@link CommandAPIPlatform} to use for the next test. This will likely be a custom
+ * implementation of {@link MockCommandAPIBukkit} that overrides a method you need to run
+ * tests that doesn't have a proper implementation in {@link MockCommandAPIBukkit}.
+ */
+ public static void usePlatformImplementation(CommandAPIPlatform, ?, ?> platform) {
+ alternativePlatform = platform;
+ }
+
+ static LoadContext getPlatform() {
+ // Default to MockCommandAPIBukkit if not given
+ CommandAPIPlatform, ?, ?> platform = alternativePlatform == null ? new MockCommandAPIBukkit() : alternativePlatform;
+
+ // Reset to avoid platform persisting between tests
+ alternativePlatform = null;
+
+ return new LoadContext(platform);
+ }
+}
diff --git a/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-test-toolkit/src/main/java/dev/jorel/commandapi/MockCommandAPIBukkit.java b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-test-toolkit/src/main/java/dev/jorel/commandapi/MockCommandAPIBukkit.java
new file mode 100644
index 0000000000..0c8e08b948
--- /dev/null
+++ b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-test-toolkit/src/main/java/dev/jorel/commandapi/MockCommandAPIBukkit.java
@@ -0,0 +1,668 @@
+package dev.jorel.commandapi;
+
+import com.mojang.brigadier.CommandDispatcher;
+import com.mojang.brigadier.Message;
+import com.mojang.brigadier.arguments.ArgumentType;
+import com.mojang.brigadier.context.CommandContext;
+import com.mojang.brigadier.exceptions.CommandSyntaxException;
+import com.mojang.brigadier.suggestion.SuggestionProvider;
+import dev.jorel.commandapi.arguments.*;
+import dev.jorel.commandapi.commandsenders.AbstractCommandSender;
+import dev.jorel.commandapi.commandsenders.BukkitCommandSender;
+import dev.jorel.commandapi.spying.CommandAPIHandlerSpy;
+import dev.jorel.commandapi.wrappers.Rotation;
+import dev.jorel.commandapi.wrappers.*;
+import net.kyori.adventure.text.Component;
+import net.kyori.adventure.text.format.NamedTextColor;
+import net.kyori.adventure.text.serializer.gson.GsonComponentSerializer;
+import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer;
+import net.md_5.bungee.api.chat.BaseComponent;
+import org.bukkit.*;
+import org.bukkit.advancement.Advancement;
+import org.bukkit.block.Block;
+import org.bukkit.block.data.BlockData;
+import org.bukkit.command.CommandSender;
+import org.bukkit.command.SimpleCommandMap;
+import org.bukkit.enchantments.Enchantment;
+import org.bukkit.entity.EntityType;
+import org.bukkit.entity.Player;
+import org.bukkit.help.HelpTopic;
+import org.bukkit.inventory.ItemStack;
+import org.bukkit.inventory.Recipe;
+import org.bukkit.loot.LootTable;
+import org.bukkit.potion.PotionEffectType;
+import org.bukkit.scoreboard.Objective;
+import org.bukkit.scoreboard.Team;
+
+import java.io.File;
+import java.io.IOException;
+import java.lang.reflect.Field;
+import java.util.*;
+import java.util.function.Function;
+import java.util.function.Predicate;
+
+/**
+ * An implementation of {@link CommandAPIBukkit} that is compatible with a MockBukkit testing environment.
+ * Does not rely on any version-specific Minecraft code to (ideally) support testing in any version.
+ */
+public class MockCommandAPIBukkit extends CommandAPIBukkit {
+ // Static instance
+ private static MockCommandAPIBukkit instance;
+
+ /**
+ * @return The {@link MockCommandAPIBukkit} instance currently loaded. This is the same object as is returned by
+ * {@link CommandAPIBukkit#get()}, but explicitly using the {@link MockCommandAPIBukkit} class.
+ */
+ public static MockCommandAPIBukkit getInstance() {
+ return instance;
+ }
+
+ protected MockCommandAPIBukkit() {
+ MockCommandAPIBukkit.instance = this;
+ }
+
+ // Reflection helpers
+
+ /**
+ * Sets a field inside a target object to the given value. Ignores private access or final status.
+ *
+ * @param targetClass The {@link Class} that contains the field.
+ * @param fieldName The name of the field to change.
+ * @param target The object whose field will be changed. If the field is static, this is ignored and can be null.
+ * @param value The new value for the field.
+ * @param The type of the target object.
+ * @throws IllegalArgumentException If the field cannot be set.
+ */
+ public static void setField(Class super Target> targetClass, String fieldName, Target target, Object value) {
+ try {
+ Field field = targetClass.getDeclaredField(fieldName);
+ field.setAccessible(true);
+ field.set(target, value);
+ } catch (ReflectiveOperationException e) {
+ throw new IllegalArgumentException("Reflection failed", e);
+ }
+ }
+
+ // References to utility classes
+ private CommandAPIHandlerSpy commandAPIHandlerSpy;
+ private MockCommandRegistrationStrategy commandRegistrationStrategy;
+
+ @Override
+ public void onLoad(CommandAPIConfig> config) {
+ // Intercept calls to CommandAPIHandler
+ commandAPIHandlerSpy = new CommandAPIHandlerSpy(CommandAPIHandler.getInstance());
+ CommandAPIHandler, CommandSender, MockCommandSource> commandAPIHandler = commandAPIHandlerSpy.spyHandler();
+ setField(CommandAPIHandler.class, "instance", null, commandAPIHandler);
+
+ // Setup objects
+ commandRegistrationStrategy = new MockCommandRegistrationStrategy(commandAPIHandler);
+
+ // Continue load
+ super.onLoad(config);
+ }
+
+ /**
+ * @return The {@link CommandAPIHandlerSpy} object intercepting calls to {@link CommandAPIHandler} methods.
+ */
+ public CommandAPIHandlerSpy getCommandAPIHandlerSpy() {
+ return commandAPIHandlerSpy;
+ }
+
+ @Override
+ public CommandRegistrationStrategy createCommandRegistrationStrategy() {
+ return commandRegistrationStrategy;
+ }
+
+ // CommandSender/MockCommandSource methods
+ @Override
+ public BukkitCommandSender extends CommandSender> getSenderForCommand(CommandContext cmdCtx, boolean forceNative) {
+ // TODO: Implement `forceNative` if necessary
+ return getCommandSenderFromCommandSource(cmdCtx.getSource());
+ }
+
+ @Override
+ public BukkitCommandSender extends CommandSender> getCommandSenderFromCommandSource(MockCommandSource cs) {
+ return super.wrapCommandSender(cs.bukkitSender());
+ }
+
+ @Override
+ public MockCommandSource getBrigadierSourceFromCommandSender(AbstractCommandSender extends CommandSender> sender) {
+ return new MockCommandSource(sender.getSource());
+ }
+
+ // Miscellaneous methods
+ /**
+ * A global toggle for whether the default logger returned by {@link #getLogger()} should print messages to the
+ * console. This is {@code false} by default, so not messages will appear. If you don't provide your own logger
+ * using {@link CommandAPI#setLogger(CommandAPILogger)} and set this to {@code true} before calling
+ * {@link CommandAPI#onLoad(CommandAPIConfig)}, then the CommandAPI will write messages into the test log.
+ */
+ public static boolean ENABLE_LOGGING = false;
+
+ @Override
+ public CommandAPILogger getLogger() {
+ return ENABLE_LOGGING ?
+ super.getLogger() :
+ CommandAPILogger.bindToMethods(msg -> {}, msg -> {}, msg -> {}, (msg, ex) -> {});
+ }
+
+ @Override
+ public Message generateMessageFromJson(String json) {
+ Component component = GsonComponentSerializer.gson().deserialize(json);
+ String text = PlainTextComponentSerializer.plainText().serialize(component);
+
+ return () -> text;
+ }
+
+ // Arguments
+ @Override
+ public ArgumentType> _ArgumentEntity(ArgumentSubType subType) {
+ return switch (subType) {
+ case ENTITYSELECTOR_MANY_ENTITIES -> EntitySelectorArgumentType.entities();
+ case ENTITYSELECTOR_MANY_PLAYERS -> EntitySelectorArgumentType.players();
+ case ENTITYSELECTOR_ONE_ENTITY -> EntitySelectorArgumentType.entity();
+ case ENTITYSELECTOR_ONE_PLAYER -> EntitySelectorArgumentType.player();
+ default -> throw new IllegalArgumentException("Unexpected value: " + subType);
+ };
+ }
+
+ @Override
+ public Object getEntitySelector(CommandContext cmdCtx, String key, ArgumentSubType subType, boolean allowEmpty) throws CommandSyntaxException {
+ return switch (subType) {
+ case ENTITYSELECTOR_MANY_ENTITIES -> EntitySelectorArgumentType.findManyEntities(cmdCtx, key, allowEmpty);
+ case ENTITYSELECTOR_MANY_PLAYERS -> EntitySelectorArgumentType.findManyPlayers(cmdCtx, key, allowEmpty);
+ case ENTITYSELECTOR_ONE_ENTITY -> EntitySelectorArgumentType.findSingleEntity(cmdCtx, key);
+ case ENTITYSELECTOR_ONE_PLAYER -> EntitySelectorArgumentType.findSinglePlayer(cmdCtx, key);
+ default -> throw new IllegalArgumentException("Unexpected value: " + subType);
+ };
+ }
+
+ @Override
+ public ArgumentType> _ArgumentIntRange() {
+ return IntegerRangeArgumentType.INSTANCE;
+ }
+
+ @Override
+ public IntegerRange getIntRange(CommandContext cmdCtx, String key) {
+ return IntegerRangeArgumentType.getRange(cmdCtx, key);
+ }
+
+ @Override
+ public ArgumentType> _ArgumentProfile() {
+ return ProfileArgumentType.INSTANCE;
+ }
+
+ @Override
+ public Player getPlayer(CommandContext cmdCtx, String key) throws CommandSyntaxException {
+ Player target = Bukkit.getPlayer(ProfileArgumentType.getProfiles(cmdCtx, key).iterator().next());
+ if (target == null) {
+ throw ProfileArgumentType.ERROR_UNKNOWN_PLAYER.create();
+ } else {
+ return target;
+ }
+ }
+
+ @Override
+ public OfflinePlayer getOfflinePlayer(CommandContext cmdCtx, String key) throws CommandSyntaxException {
+ return Bukkit.getOfflinePlayer(ProfileArgumentType.getProfiles(cmdCtx, key).iterator().next());
+ }
+
+ ///////////////////////////
+ // UNIMPLEMENTED METHODS //
+ ///////////////////////////
+
+ @Override
+ public ArgumentType> _ArgumentAdvancement() {
+ throw new UnimplementedMethodException();
+ }
+
+ @Override
+ public ArgumentType> _ArgumentAngle() {
+ throw new UnimplementedMethodException();
+ }
+
+ @Override
+ public ArgumentType> _ArgumentAxis() {
+ throw new UnimplementedMethodException();
+ }
+
+ @Override
+ public ArgumentType> _ArgumentBlockPredicate() {
+ throw new UnimplementedMethodException();
+ }
+
+ @Override
+ public ArgumentType> _ArgumentBlockState() {
+ throw new UnimplementedMethodException();
+ }
+
+ @Override
+ public ArgumentType> _ArgumentChat() {
+ throw new UnimplementedMethodException();
+ }
+
+ @Override
+ public ArgumentType> _ArgumentChatComponent() {
+ throw new UnimplementedMethodException();
+ }
+
+ @Override
+ public ArgumentType> _ArgumentChatFormat() {
+ throw new UnimplementedMethodException();
+ }
+
+ @Override
+ public ArgumentType> _ArgumentDimension() {
+ throw new UnimplementedMethodException();
+ }
+
+ @Override
+ public ArgumentType> _ArgumentEnchantment() {
+ throw new UnimplementedMethodException();
+ }
+
+ @Override
+ public ArgumentType> _ArgumentEntitySummon() {
+ throw new UnimplementedMethodException();
+ }
+
+ @Override
+ public ArgumentType> _ArgumentFloatRange() {
+ throw new UnimplementedMethodException();
+ }
+
+ @Override
+ public ArgumentType> _ArgumentItemPredicate() {
+ throw new UnimplementedMethodException();
+ }
+
+ @Override
+ public ArgumentType> _ArgumentItemStack() {
+ throw new UnimplementedMethodException();
+ }
+
+ @Override
+ public ArgumentType> _ArgumentMathOperation() {
+ throw new UnimplementedMethodException();
+ }
+
+ @Override
+ public ArgumentType> _ArgumentMinecraftKeyRegistered() {
+ throw new UnimplementedMethodException();
+ }
+
+ @Override
+ public ArgumentType> _ArgumentMobEffect() {
+ throw new UnimplementedMethodException();
+ }
+
+ @Override
+ public ArgumentType> _ArgumentNBTCompound() {
+ throw new UnimplementedMethodException();
+ }
+
+ @Override
+ public ArgumentType> _ArgumentParticle() {
+ throw new UnimplementedMethodException();
+ }
+
+ @Override
+ public ArgumentType> _ArgumentPosition() {
+ throw new UnimplementedMethodException();
+ }
+
+ @Override
+ public ArgumentType> _ArgumentPosition2D() {
+ throw new UnimplementedMethodException();
+ }
+
+ @Override
+ public ArgumentType> _ArgumentRecipe() {
+ throw new UnimplementedMethodException();
+ }
+
+ @Override
+ public ArgumentType> _ArgumentRotation() {
+ throw new UnimplementedMethodException();
+ }
+
+ @Override
+ public ArgumentType> _ArgumentScoreboardCriteria() {
+ throw new UnimplementedMethodException();
+ }
+
+ @Override
+ public ArgumentType> _ArgumentScoreboardObjective() {
+ throw new UnimplementedMethodException();
+ }
+
+ @Override
+ public ArgumentType> _ArgumentScoreboardSlot() {
+ throw new UnimplementedMethodException();
+ }
+
+ @Override
+ public ArgumentType> _ArgumentScoreboardTeam() {
+ throw new UnimplementedMethodException();
+ }
+
+ @Override
+ public ArgumentType> _ArgumentScoreholder(ArgumentSubType subType) {
+ throw new UnimplementedMethodException();
+ }
+
+ @Override
+ public ArgumentType> _ArgumentTag() {
+ throw new UnimplementedMethodException();
+ }
+
+ @Override
+ public ArgumentType> _ArgumentTime() {
+ throw new UnimplementedMethodException();
+ }
+
+ @Override
+ public ArgumentType> _ArgumentUUID() {
+ throw new UnimplementedMethodException();
+ }
+
+ @Override
+ public ArgumentType> _ArgumentVec2(boolean centerPosition) {
+ throw new UnimplementedMethodException();
+ }
+
+ @Override
+ public ArgumentType> _ArgumentVec3(boolean centerPosition) {
+ throw new UnimplementedMethodException();
+ }
+
+ @Override
+ public ArgumentType> _ArgumentSyntheticBiome() {
+ throw new UnimplementedMethodException();
+ }
+
+ @Override
+ public String[] compatibleVersions() {
+ throw new UnimplementedMethodException();
+ }
+
+ @Override
+ public String convert(ItemStack is) {
+ throw new UnimplementedMethodException();
+ }
+
+ @Override
+ public String convert(ParticleData> particle) {
+ throw new UnimplementedMethodException();
+ }
+
+ @Override
+ public String convert(PotionEffectType potion) {
+ throw new UnimplementedMethodException();
+ }
+
+ @Override
+ public String convert(Sound sound) {
+ throw new UnimplementedMethodException();
+ }
+
+ @Override
+ public Advancement getAdvancement(CommandContext cmdCtx, String key) throws CommandSyntaxException {
+ throw new UnimplementedMethodException();
+ }
+
+ @Override
+ public Component getAdventureChat(CommandContext cmdCtx, String key) throws CommandSyntaxException {
+ throw new UnimplementedMethodException();
+ }
+
+ @Override
+ public NamedTextColor getAdventureChatColor(CommandContext cmdCtx, String key) {
+ throw new UnimplementedMethodException();
+ }
+
+ @Override
+ public Component getAdventureChatComponent(CommandContext cmdCtx, String key) {
+ throw new UnimplementedMethodException();
+ }
+
+ @Override
+ public float getAngle(CommandContext cmdCtx, String key) {
+ throw new UnimplementedMethodException();
+ }
+
+ @Override
+ public EnumSet getAxis(CommandContext cmdCtx, String key) {
+ throw new UnimplementedMethodException();
+ }
+
+ @Override
+ public Object getBiome(CommandContext cmdCtx, String key, ArgumentSubType subType) throws CommandSyntaxException {
+ throw new UnimplementedMethodException();
+ }
+
+ @Override
+ public Predicate getBlockPredicate(CommandContext cmdCtx, String key) throws CommandSyntaxException {
+ throw new UnimplementedMethodException();
+ }
+
+ @Override
+ public BlockData getBlockState(CommandContext cmdCtx, String key) {
+ throw new UnimplementedMethodException();
+ }
+
+ @Override
+ public BaseComponent[] getChat(CommandContext cmdCtx, String key) throws CommandSyntaxException {
+ throw new UnimplementedMethodException();
+ }
+
+ @Override
+ public ChatColor getChatColor(CommandContext cmdCtx, String key) {
+ throw new UnimplementedMethodException();
+ }
+
+ @Override
+ public BaseComponent[] getChatComponent(CommandContext cmdCtx, String key) {
+ throw new UnimplementedMethodException();
+ }
+
+ @Override
+ public World getDimension(CommandContext cmdCtx, String key) throws CommandSyntaxException {
+ throw new UnimplementedMethodException();
+ }
+
+ @Override
+ public Enchantment getEnchantment(CommandContext cmdCtx, String key) throws CommandSyntaxException {
+ throw new UnimplementedMethodException();
+ }
+
+ @Override
+ public EntityType getEntityType(CommandContext cmdCtx, String key) throws CommandSyntaxException {
+ throw new UnimplementedMethodException();
+ }
+
+ @Override
+ public FloatRange getFloatRange(CommandContext cmdCtx, String key) {
+ throw new UnimplementedMethodException();
+ }
+
+ @Override
+ public FunctionWrapper[] getFunction(CommandContext cmdCtx, String key) throws CommandSyntaxException {
+ throw new UnimplementedMethodException();
+ }
+
+ @Override
+ public SimpleFunctionWrapper getFunction(NamespacedKey key) {
+ throw new UnimplementedMethodException();
+ }
+
+ @Override
+ public Set getFunctions() {
+ throw new UnimplementedMethodException();
+ }
+
+ @Override
+ public ItemStack getItemStack(CommandContext cmdCtx, String key) throws CommandSyntaxException {
+ throw new UnimplementedMethodException();
+ }
+
+ @Override
+ public Predicate getItemStackPredicate(CommandContext cmdCtx, String key) throws CommandSyntaxException {
+ throw new UnimplementedMethodException();
+ }
+
+ @Override
+ public Location2D getLocation2DBlock(CommandContext cmdCtx, String key) throws CommandSyntaxException {
+ throw new UnimplementedMethodException();
+ }
+
+ @Override
+ public Location2D getLocation2DPrecise(CommandContext cmdCtx, String key) throws CommandSyntaxException {
+ throw new UnimplementedMethodException();
+ }
+
+ @Override
+ public Location getLocationBlock(CommandContext cmdCtx, String str) throws CommandSyntaxException {
+ throw new UnimplementedMethodException();
+ }
+
+ @Override
+ public Location getLocationPrecise(CommandContext cmdCtx, String str) throws CommandSyntaxException {
+ throw new UnimplementedMethodException();
+ }
+
+ @Override
+ public LootTable getLootTable(CommandContext cmdCtx, String key) {
+ throw new UnimplementedMethodException();
+ }
+
+ @Override
+ public MathOperation getMathOperation(CommandContext cmdCtx, String key) throws CommandSyntaxException {
+ throw new UnimplementedMethodException();
+ }
+
+ @Override
+ public NamespacedKey getMinecraftKey(CommandContext cmdCtx, String key) {
+ throw new UnimplementedMethodException();
+ }
+
+ @Override
+ public Object getNBTCompound(CommandContext cmdCtx, String key, Function nbtContainerConstructor) {
+ throw new UnimplementedMethodException();
+ }
+
+ @Override
+ public Objective getObjective(CommandContext cmdCtx, String key) throws IllegalArgumentException, CommandSyntaxException {
+ throw new UnimplementedMethodException();
+ }
+
+ @Override
+ public String getObjectiveCriteria(CommandContext cmdCtx, String key) {
+ throw new UnimplementedMethodException();
+ }
+
+ @Override
+ public ParticleData> getParticle(CommandContext cmdCtx, String key) {
+ throw new UnimplementedMethodException();
+ }
+
+ @Override
+ public Object getPotionEffect(CommandContext cmdCtx, String key, ArgumentSubType subType) throws CommandSyntaxException {
+ throw new UnimplementedMethodException();
+ }
+
+ @Override
+ public Recipe getRecipe(CommandContext cmdCtx, String key) throws CommandSyntaxException {
+ throw new UnimplementedMethodException();
+ }
+
+ @Override
+ public Rotation getRotation(CommandContext cmdCtx, String key) {
+ throw new UnimplementedMethodException();
+ }
+
+ @Override
+ public ScoreboardSlot getScoreboardSlot(CommandContext cmdCtx, String key) {
+ throw new UnimplementedMethodException();
+ }
+
+ @Override
+ public Collection getScoreHolderMultiple(CommandContext cmdCtx, String key) throws CommandSyntaxException {
+ throw new UnimplementedMethodException();
+ }
+
+ @Override
+ public String getScoreHolderSingle(CommandContext cmdCtx, String key) throws CommandSyntaxException {
+ throw new UnimplementedMethodException();
+ }
+
+ @Override
+ public Team getTeam(CommandContext cmdCtx, String key) throws CommandSyntaxException {
+ throw new UnimplementedMethodException();
+ }
+
+ @Override
+ public int getTime(CommandContext cmdCtx, String key) {
+ throw new UnimplementedMethodException();
+ }
+
+ @Override
+ public UUID getUUID(CommandContext cmdCtx, String key) {
+ throw new UnimplementedMethodException();
+ }
+
+ @Override
+ public World getWorldForCSS(MockCommandSource clw) {
+ throw new UnimplementedMethodException();
+ }
+
+ @Override
+ public SimpleCommandMap getSimpleCommandMap() {
+ throw new UnimplementedMethodException();
+ }
+
+ @Override
+ public Object getSound(CommandContext cmdCtx, String key, ArgumentSubType subType) {
+ throw new UnimplementedMethodException();
+ }
+
+ @Override
+ public SuggestionProvider getSuggestionProvider(SuggestionProviders suggestionProvider) {
+ throw new UnimplementedMethodException();
+ }
+
+ @Override
+ public SimpleFunctionWrapper[] getTag(NamespacedKey key) {
+ throw new UnimplementedMethodException();
+ }
+
+ @Override
+ public Set getTags() {
+ throw new UnimplementedMethodException();
+ }
+
+ @Override
+ public void createDispatcherFile(File file, CommandDispatcher brigadierDispatcher) throws IOException {
+ throw new UnimplementedMethodException();
+ }
+
+ @Override
+ public T getMinecraftServer() {
+ throw new UnimplementedMethodException();
+ }
+
+ @Override
+ public void reloadDataPacks() {
+ throw new UnimplementedMethodException();
+ }
+
+ @Override
+ public HelpTopic generateHelpTopic(String commandName, String shortDescription, String fullDescription, String permission) {
+ throw new UnimplementedMethodException();
+ }
+
+ @Override
+ public Map getHelpMap() {
+ throw new UnimplementedMethodException();
+ }
+}
diff --git a/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-test-toolkit/src/main/java/dev/jorel/commandapi/MockCommandAPIPlugin.java b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-test-toolkit/src/main/java/dev/jorel/commandapi/MockCommandAPIPlugin.java
new file mode 100644
index 0000000000..1b29fcd14a
--- /dev/null
+++ b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-test-toolkit/src/main/java/dev/jorel/commandapi/MockCommandAPIPlugin.java
@@ -0,0 +1,62 @@
+package dev.jorel.commandapi;
+
+import be.seeseemelk.mockbukkit.MockBukkit;
+import org.bukkit.plugin.java.JavaPlugin;
+
+import java.util.function.Consumer;
+
+/**
+ * A {@link JavaPlugin} that loads the CommandAPI in a testing environment. If your plugin depends on the CommandAPI
+ * plugin when running on a server (you aren't shading the CommandAPI), load this class before loading your own plugin
+ * with {@link MockBukkit}. You can do this directly using {@code MockBukkit.load(MockCommandAPIPlugin.class);}, or with
+ * {@link MockCommandAPIPlugin#load()}. If you need to configure settings usually located in the CommandAPI's plugin.yml,
+ * use {@link MockCommandAPIPlugin#load(Consumer)}.
+ */
+public class MockCommandAPIPlugin extends JavaPlugin {
+ // Allow loading with settings,
+ // Default to none if `MockBukkit.load(MockCommandAPIPlugin.class)` is used directly
+ private static Consumer configureSettings = null;
+
+ /**
+ * Loads the CommandAPI plugin using {@link MockBukkit#load(Class)}.
+ *
+ * @return The {@link MockCommandAPIPlugin} instance that was loaded.
+ */
+ public static MockCommandAPIPlugin load() {
+ return load(null);
+ }
+
+ /**
+ * Loads the CommandAPI plugin using {@link MockBukkit#load(Class)}.
+ *
+ * @param configureSettings A {@link Consumer} that can configure the settings of the {@link CommandAPIBukkitConfig}
+ * before it is used to load the CommandAPI plugin.
+ * @return The {@link MockCommandAPIPlugin} instance that was loaded.
+ */
+ public static MockCommandAPIPlugin load(Consumer configureSettings) {
+ MockCommandAPIPlugin.configureSettings = configureSettings;
+ return MockBukkit.load(MockCommandAPIPlugin.class);
+ }
+
+ @Override
+ public void onLoad() {
+ CommandAPIBukkitConfig config = new CommandAPIBukkitConfig(this);
+
+ if (configureSettings != null) {
+ configureSettings.accept(config);
+ }
+ configureSettings = null; // Reset to avoid configs persisting between tests
+
+ CommandAPI.onLoad(config);
+ }
+
+ @Override
+ public void onEnable() {
+ CommandAPI.onEnable();
+ }
+
+ @Override
+ public void onDisable() {
+ CommandAPI.onDisable();
+ }
+}
diff --git a/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-test-toolkit/src/main/java/dev/jorel/commandapi/MockCommandRegistrationStrategy.java b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-test-toolkit/src/main/java/dev/jorel/commandapi/MockCommandRegistrationStrategy.java
new file mode 100644
index 0000000000..dd692ae449
--- /dev/null
+++ b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-test-toolkit/src/main/java/dev/jorel/commandapi/MockCommandRegistrationStrategy.java
@@ -0,0 +1,62 @@
+package dev.jorel.commandapi;
+
+import com.mojang.brigadier.CommandDispatcher;
+import com.mojang.brigadier.builder.LiteralArgumentBuilder;
+import com.mojang.brigadier.tree.LiteralCommandNode;
+import com.mojang.brigadier.tree.RootCommandNode;
+import dev.jorel.commandapi.arguments.Argument;
+import org.bukkit.command.CommandSender;
+
+import java.util.List;
+
+/**
+ * An implementation of {@link CommandRegistrationStrategy} that handles registering commands in a testing environment.
+ */
+public class MockCommandRegistrationStrategy extends CommandRegistrationStrategy {
+ private final CommandAPIHandler, CommandSender, MockCommandSource> commandAPIHandler;
+ private final CommandDispatcher dispatcher = new CommandDispatcher<>();
+
+ protected MockCommandRegistrationStrategy(CommandAPIHandler, CommandSender, MockCommandSource> commandAPIHandler) {
+ this.commandAPIHandler = commandAPIHandler;
+ }
+
+ @Override
+ public CommandDispatcher getBrigadierDispatcher() {
+ return dispatcher;
+ }
+
+ @Override
+ public LiteralCommandNode registerCommandNode(LiteralArgumentBuilder builder, String namespace) {
+ RootCommandNode root = dispatcher.getRoot();
+ LiteralCommandNode node = builder.build();
+
+ root.addChild(node);
+ root.addChild(commandAPIHandler.namespaceNode(node, namespace));
+
+ return node;
+ }
+
+ @Override
+ public void postCommandRegistration(RegisteredCommand registeredCommand, LiteralCommandNode resultantNode, List> aliasNodes) {
+ // Nothing to do
+ }
+
+ ///////////////////////////
+ // UNIMPLEMENTED METHODS //
+ ///////////////////////////
+
+ @Override
+ public void runTasksAfterServerStart() {
+ throw new UnimplementedMethodException();
+ }
+
+ @Override
+ public void unregister(String commandName, boolean unregisterNamespaces, boolean unregisterBukkit) {
+ throw new UnimplementedMethodException();
+ }
+
+ @Override
+ public void preReloadDataPacks() {
+ throw new UnimplementedMethodException();
+ }
+}
diff --git a/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-test-toolkit/src/main/java/dev/jorel/commandapi/MockCommandSource.java b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-test-toolkit/src/main/java/dev/jorel/commandapi/MockCommandSource.java
new file mode 100644
index 0000000000..c2cd93912a
--- /dev/null
+++ b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-test-toolkit/src/main/java/dev/jorel/commandapi/MockCommandSource.java
@@ -0,0 +1,34 @@
+package dev.jorel.commandapi;
+
+import com.google.common.base.Preconditions;
+import dev.jorel.commandapi.wrappers.NativeProxyCommandSender;
+import org.bukkit.Location;
+import org.bukkit.command.BlockCommandSender;
+import org.bukkit.command.CommandSender;
+import org.bukkit.entity.Entity;
+
+/**
+ * An NMS independent implementation of CommandSourceStack. Used by Brigadier when parsing and executing commands.
+ *
+ * @param bukkitSender The Bukkit {@link CommandSender} this source represents.
+ * @param location The {@link Location} where this source is running the command from.
+ * @param entity The {@link Entity} this source is running the command as.
+ */
+public record MockCommandSource(CommandSender bukkitSender, Location location, Entity entity) {
+ public MockCommandSource(CommandSender bukkitSender) {
+ this(Preconditions.checkNotNull(bukkitSender), getLocation(bukkitSender), getEntity(bukkitSender));
+ }
+
+ private static Location getLocation(CommandSender sender) {
+ return switch (sender) {
+ case Entity entity -> entity.getLocation();
+ case BlockCommandSender block -> block.getBlock().getLocation();
+ case NativeProxyCommandSender proxy -> proxy.getLocation();
+ default -> new Location(null, 0, 0, 0);
+ };
+ }
+
+ private static Entity getEntity(CommandSender sender) {
+ return sender instanceof Entity entity ? entity : null;
+ }
+}
diff --git a/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-test-toolkit/src/main/java/dev/jorel/commandapi/UnimplementedMethodException.java b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-test-toolkit/src/main/java/dev/jorel/commandapi/UnimplementedMethodException.java
new file mode 100644
index 0000000000..a6f757be4b
--- /dev/null
+++ b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-test-toolkit/src/main/java/dev/jorel/commandapi/UnimplementedMethodException.java
@@ -0,0 +1,17 @@
+package dev.jorel.commandapi;
+
+/**
+ * An exception thrown when a method is called that hasn't been implemented yet.
+ */
+// This is the first way I thought to highlight where methods are not implemented
+// Though all the warnings may be a little unnecessary ¯\_(ツ)_/¯
+@Deprecated(since = "TODO: Implement this method")
+public class UnimplementedMethodException extends RuntimeException {
+ public UnimplementedMethodException() {
+ super("This method has not been implemented - CommandAPI");
+ }
+
+ public UnimplementedMethodException(String reason) {
+ super(reason + " ==> This method has not been implemented - CommandAPI");
+ }
+}
diff --git a/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-test-toolkit/src/main/java/dev/jorel/commandapi/arguments/ArgumentUtilities.java b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-test-toolkit/src/main/java/dev/jorel/commandapi/arguments/ArgumentUtilities.java
new file mode 100644
index 0000000000..dd8a4861da
--- /dev/null
+++ b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-test-toolkit/src/main/java/dev/jorel/commandapi/arguments/ArgumentUtilities.java
@@ -0,0 +1,109 @@
+package dev.jorel.commandapi.arguments;
+
+import com.mojang.brigadier.Message;
+import com.mojang.brigadier.StringReader;
+import com.mojang.brigadier.exceptions.BuiltInExceptionProvider;
+import com.mojang.brigadier.exceptions.CommandSyntaxException;
+import dev.jorel.commandapi.BukkitTooltip;
+import dev.jorel.commandapi.arguments.parser.ParserArgument;
+import dev.jorel.commandapi.arguments.parser.ParserLiteral;
+import net.kyori.adventure.text.Component;
+import net.kyori.adventure.text.TextComponent;
+
+/**
+ * Utilities for creating mock argument parsers
+ */
+public class ArgumentUtilities {
+ private ArgumentUtilities() {
+ }
+
+ // Translatable messages
+ /**
+ * Turns a translation key into a Brigadier {@link Message}. These keys can be a default
+ * Minecraft Language Code
+ * or any translation loaded by a plugin.
+ *
+ * @param key The key for the message translation.
+ * @return A Brigadier {@link Message} that represents the given language key.
+ */
+ public static Message translatedMessage(String key) {
+ return BukkitTooltip.messageFromAdventureComponent(Component.translatable(key));
+ }
+
+ /**
+ * Turns a translation key into a Brigadier {@link Message}. These keys can be a default
+ * Minecraft Language Code
+ * or any translation loaded by a plugin.
+ *
+ * @param key The key for the message translation.
+ * @param args Objects to insert into the string at the location of
+ * {@code %s} markers in the resulting translated message.
+ * @return A Brigadier {@link Message} that represents the given language key.
+ */
+ public static Message translatedMessage(String key, Object... args) {
+ TextComponent[] argsComponents = new TextComponent[args.length];
+ for (int i = 0; i < args.length; i++) {
+ Object arg = args[i];
+ String text = arg.toString();
+ argsComponents[i] = Component.text(text);
+ }
+ return BukkitTooltip.messageFromAdventureComponent(Component.translatable(key, argsComponents));
+ }
+
+ // Parser utilities
+
+ /**
+ * Returns a new {@link ParserLiteral}. When the returned parser is invoked, it tries to read the given
+ * {@code literal} String from the input {@link StringReader}. If the {@code literal} is present, this parser
+ * succeeds and moves {@link StringReader#getCursor()} to the end of the {@code literal}. Otherwise, this parser
+ * will fail and throw a {@link CommandSyntaxException} with type {@link BuiltInExceptionProvider#literalIncorrect()}.
+ *
+ * @param literal The exact String that is expected to be at the start of the input {@link StringReader}.
+ * @return A {@link ParserLiteral} that checks if the {@code literal} String can be read from the input {@link StringReader}.
+ */
+ public static ParserLiteral literal(String literal) {
+ return reader -> {
+ if (reader.canRead(literal.length())) {
+ int start = reader.getCursor();
+ int end = start + literal.length();
+
+ if (reader.getString().substring(start, end).equals(literal)) {
+ reader.setCursor(end);
+ return;
+ }
+ }
+ throw CommandSyntaxException.BUILT_IN_EXCEPTIONS.literalIncorrect().createWithContext(reader, literal);
+ };
+ }
+
+ /**
+ * Returns a new {@link ParserArgument} that reads characters from the input {@link StringReader} until it reaches
+ * the given terminator character. If the terminator character is not found, the entire
+ * {@link StringReader#getRemaining()} String will be read.
+ *
+ * Note - This is intended to work like {@link StringReader#readStringUntil(char)} but with two differences:
+ * 1. This method does not treat {@code \} as a special character. {@link StringReader#readStringUntil(char)} allows
+ * the terminator character to be escaped by unescaped {@code \}.
+ * 2. This method does not throw a {@link CommandSyntaxException} if the end of the string is reached without a terminator.
+ * {@link StringReader#readStringUntil(char)} will throw {@link BuiltInExceptionProvider#readerExpectedEndOfQuote()}
+ * if this happens.
+ *
+ * @param terminator The character to stop reading at.
+ * @return A {@link ParserArgument} that reads until it finds the given terminator. Note that the returned String will
+ * include the terminator at the end, unless the end of the input {@link StringReader} is reached without finding the
+ * terminator.
+ */
+ public static ParserArgument readUntilWithoutEscapeCharacter(char terminator) {
+ return reader -> {
+ int start = reader.getCursor();
+
+ int end = reader.getString().indexOf(terminator, start);
+ if (end == -1) {
+ end = reader.getTotalLength();
+ }
+
+ reader.setCursor(end);
+ return reader.getString().substring(start, end);
+ };
+ }
+}
diff --git a/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-test-toolkit/src/main/java/dev/jorel/commandapi/arguments/EntitySelector.java b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-test-toolkit/src/main/java/dev/jorel/commandapi/arguments/EntitySelector.java
new file mode 100644
index 0000000000..cd1668503f
--- /dev/null
+++ b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-test-toolkit/src/main/java/dev/jorel/commandapi/arguments/EntitySelector.java
@@ -0,0 +1,100 @@
+package dev.jorel.commandapi.arguments;
+
+import dev.jorel.commandapi.MockCommandSource;
+import org.bukkit.Bukkit;
+import org.bukkit.Location;
+import org.bukkit.World;
+import org.bukkit.entity.Entity;
+import org.bukkit.entity.EntityType;
+import org.bukkit.entity.Player;
+
+import java.util.*;
+import java.util.function.Predicate;
+
+public record EntitySelector(
+ int maxResults, boolean includesEntities, Order order, boolean selfSelector, String playerName,
+ Predicate entityCheck, UUID entityUUID, EntityType type
+) {
+ // Sorting orders
+ @FunctionalInterface
+ public interface Order {
+ void sort(Location location, List extends Entity> entities);
+ }
+
+ public static final Order ORDER_ARBITRARY = (location, entities) -> {
+ };
+
+ public static final Order ORDER_NEAREST = (location, entities) ->
+ entities.sort(Comparator.comparingDouble(entity -> entity.getLocation().distanceSquared(location)));
+
+ public static final Order ORDER_FURTHEST = (location, entities) ->
+ entities.sort(Comparator.comparingDouble(entity -> -entity.getLocation().distanceSquared(location)));
+
+ public static final Order ORDER_RANDOM = (location, entities) -> Collections.shuffle(entities);
+
+ // Retrieve entities
+ public List extends Entity> findEntities(MockCommandSource source) {
+ if (!includesEntities) {
+ return findPlayers(source);
+ } else if (playerName != null) {
+ Player player = Bukkit.getPlayer(playerName);
+ return player == null ? List.of() : List.of(player);
+ } else if (entityUUID != null) {
+ for (World world : Bukkit.getServer().getWorlds()) {
+ // Note that Paper does have the method `World#getEntity(UUID)`, which would be more accurate
+ // to the vanilla implementation, but MockBukkit doesn't implement that method, so this works too.
+ for (Entity entity : world.getEntities()) {
+ if (entity.getUniqueId().equals(entityUUID)) {
+ return List.of(entity);
+ }
+ }
+ }
+ return List.of();
+ } else if (selfSelector) {
+ Entity entity = source.entity();
+ return (entity != null && entityCheck.test(entity)) ? List.of(entity) : List.of();
+ } else {
+ List entities = new ArrayList<>();
+ for (World world : Bukkit.getServer().getWorlds()) {
+ for (Entity entity : world.getEntities()) {
+ if (
+ (type == null || entity.getType().equals(type))
+ && entityCheck.test(entity)
+ ) {
+ entities.add(entity);
+ }
+ }
+ }
+ return sortAndLimit(source.location(), entities);
+ }
+ }
+
+ public List findPlayers(MockCommandSource source) {
+ if (playerName != null) {
+ Player player = Bukkit.getPlayer(playerName);
+ return player == null ? List.of() : List.of(player);
+ } else if (entityUUID != null) {
+ Player player = Bukkit.getPlayer(entityUUID);
+ return player == null ? List.of() : List.of(player);
+ } else if (selfSelector) {
+ Entity entity = source.entity();
+ return (entity instanceof Player player && entityCheck.test(entity)) ? List.of(player) : List.of();
+ } else {
+ List players = new ArrayList<>();
+ for (Player player : Bukkit.getOnlinePlayers()) {
+ if (entityCheck.test(player)) {
+ players.add(player);
+ }
+ }
+ return sortAndLimit(source.location(), players);
+ }
+ }
+
+ private List sortAndLimit(Location originLocation, List entities) {
+ if (entities.size() > 1) {
+ order.sort(originLocation, entities);
+ }
+
+ return entities.subList(0, Math.min(maxResults, entities.size()));
+ }
+}
diff --git a/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-test-toolkit/src/main/java/dev/jorel/commandapi/arguments/EntitySelectorArgumentType.java b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-test-toolkit/src/main/java/dev/jorel/commandapi/arguments/EntitySelectorArgumentType.java
new file mode 100644
index 0000000000..72f0469945
--- /dev/null
+++ b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-test-toolkit/src/main/java/dev/jorel/commandapi/arguments/EntitySelectorArgumentType.java
@@ -0,0 +1,113 @@
+package dev.jorel.commandapi.arguments;
+
+import com.mojang.brigadier.StringReader;
+import com.mojang.brigadier.arguments.ArgumentType;
+import com.mojang.brigadier.context.CommandContext;
+import com.mojang.brigadier.exceptions.CommandSyntaxException;
+import com.mojang.brigadier.exceptions.SimpleCommandExceptionType;
+import com.mojang.brigadier.suggestion.Suggestions;
+import com.mojang.brigadier.suggestion.SuggestionsBuilder;
+import dev.jorel.commandapi.MockCommandSource;
+import org.bukkit.entity.Entity;
+import org.bukkit.entity.Player;
+
+import java.util.List;
+import java.util.concurrent.CompletableFuture;
+
+public record EntitySelectorArgumentType(boolean singleTarget, boolean playersOnly) implements ArgumentType {
+ // Internal state necessary
+ public static EntitySelectorArgumentType entities() {
+ return new EntitySelectorArgumentType(false, false);
+ }
+
+ public static EntitySelectorArgumentType players() {
+ return new EntitySelectorArgumentType(false, true);
+ }
+
+ public static EntitySelectorArgumentType entity() {
+ return new EntitySelectorArgumentType(true, false);
+ }
+
+ public static EntitySelectorArgumentType player() {
+ return new EntitySelectorArgumentType(true, true);
+ }
+
+ // ArgumentType implementation
+ public static final SimpleCommandExceptionType ERROR_NOT_SINGLE_ENTITY = new SimpleCommandExceptionType(
+ ArgumentUtilities.translatedMessage("argument.entity.toomany")
+ );
+ public static final SimpleCommandExceptionType ERROR_NOT_SINGLE_PLAYER = new SimpleCommandExceptionType(
+ ArgumentUtilities.translatedMessage("argument.player.toomany")
+ );
+ public static final SimpleCommandExceptionType ERROR_ONLY_PLAYERS_ALLOWED = new SimpleCommandExceptionType(
+ ArgumentUtilities.translatedMessage("argument.player.entities")
+ );
+ public static final SimpleCommandExceptionType NO_ENTITIES_FOUND = new SimpleCommandExceptionType(
+ ArgumentUtilities.translatedMessage("argument.entity.notfound.entity")
+ );
+ public static final SimpleCommandExceptionType NO_PLAYERS_FOUND = new SimpleCommandExceptionType(
+ ArgumentUtilities.translatedMessage("argument.entity.notfound.player")
+ );
+
+ @Override
+ public EntitySelector parse(StringReader reader) throws CommandSyntaxException {
+ EntitySelector entityselector = EntitySelectorParser.parser.parse(reader);
+ // I don't know why Minecraft does `reader.setCursor(0)` here before throwing exceptions, but it does ¯\_(ツ)_/¯
+ // That has the goofy result of underlining the whole command when it should really only underline the selector
+ // This is easily fixed, just store `reader.getCursor()` before parsing the selector
+ if (entityselector.maxResults() > 1 && this.singleTarget) {
+ if (this.playersOnly) {
+ reader.setCursor(0);
+ throw ERROR_NOT_SINGLE_PLAYER.createWithContext(reader);
+ } else {
+ reader.setCursor(0);
+ throw ERROR_NOT_SINGLE_ENTITY.createWithContext(reader);
+ }
+ } else if (entityselector.includesEntities() && this.playersOnly && !entityselector.selfSelector()) {
+ reader.setCursor(0);
+ throw ERROR_ONLY_PLAYERS_ALLOWED.createWithContext(reader);
+ } else {
+ return entityselector;
+ }
+ }
+
+ @Override
+ public CompletableFuture listSuggestions(CommandContext context, SuggestionsBuilder builder) {
+ return EntitySelectorParser.parser.listSuggestions(context, builder);
+ }
+
+ public static List extends Entity> findManyEntities(CommandContext cmdCtx, String key, boolean allowEmpty) throws CommandSyntaxException {
+ EntitySelector selector = cmdCtx.getArgument(key, EntitySelector.class);
+ List extends Entity> entities = selector.findEntities(cmdCtx.getSource());
+ if (entities.isEmpty() && !allowEmpty) {
+ throw NO_ENTITIES_FOUND.create();
+ }
+ return entities;
+ }
+
+ public static List findManyPlayers(CommandContext cmdCtx, String key, boolean allowEmpty) throws CommandSyntaxException {
+ EntitySelector selector = cmdCtx.getArgument(key, EntitySelector.class);
+ List players = selector.findPlayers(cmdCtx.getSource());
+ if (players.isEmpty() && !allowEmpty) {
+ throw NO_PLAYERS_FOUND.create();
+ }
+ return players;
+ }
+
+ // Funnily, the logic for finding single entity vs player is different, at least on 1.21 ¯\_(ツ)_/¯
+ public static Entity findSingleEntity(CommandContext cmdCtx, String key) throws CommandSyntaxException {
+ List extends Entity> entities = findManyEntities(cmdCtx, key, false);
+ if (entities.size() > 1) {
+ throw ERROR_NOT_SINGLE_ENTITY.create();
+ }
+ return entities.getFirst();
+ }
+
+ public static Player findSinglePlayer(CommandContext cmdCtx, String key) throws CommandSyntaxException {
+ List players = findManyPlayers(cmdCtx, key, true);
+ if (players.size() != 1) {
+ throw NO_PLAYERS_FOUND.create();
+ }
+ return players.getFirst();
+ }
+}
diff --git a/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-test-toolkit/src/main/java/dev/jorel/commandapi/arguments/EntitySelectorParser.java b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-test-toolkit/src/main/java/dev/jorel/commandapi/arguments/EntitySelectorParser.java
new file mode 100644
index 0000000000..db1235c692
--- /dev/null
+++ b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-test-toolkit/src/main/java/dev/jorel/commandapi/arguments/EntitySelectorParser.java
@@ -0,0 +1,201 @@
+package dev.jorel.commandapi.arguments;
+
+import com.mojang.brigadier.exceptions.DynamicCommandExceptionType;
+import com.mojang.brigadier.exceptions.SimpleCommandExceptionType;
+import dev.jorel.commandapi.UnimplementedMethodException;
+import dev.jorel.commandapi.arguments.parser.Parser;
+import dev.jorel.commandapi.arguments.parser.ParserLiteral;
+import dev.jorel.commandapi.arguments.parser.Result;
+import dev.jorel.commandapi.arguments.parser.SuggestionProvider;
+import org.bukkit.Bukkit;
+import org.bukkit.entity.Entity;
+import org.bukkit.entity.EntityType;
+
+import java.util.UUID;
+import java.util.function.Function;
+import java.util.function.Predicate;
+
+public class EntitySelectorParser {
+ // Errors
+ public static final SimpleCommandExceptionType ERROR_INVALID_NAME_OR_UUID = new SimpleCommandExceptionType(
+ ArgumentUtilities.translatedMessage("argument.entity.invalid")
+ );
+ public static final DynamicCommandExceptionType ERROR_UNKNOWN_SELECTOR_TYPE = new DynamicCommandExceptionType(
+ object -> ArgumentUtilities.translatedMessage("argument.entity.selector.unknown", object)
+ );
+ public static final SimpleCommandExceptionType ERROR_MISSING_SELECTOR_TYPE = new SimpleCommandExceptionType(
+ ArgumentUtilities.translatedMessage("argument.entity.selector.missing")
+ );
+
+ // State for building a selector
+ private EntitySelectorParser() {
+
+ }
+
+ private int maxResults;
+ private boolean includesEntities;
+ private EntitySelector.Order order = EntitySelector.ORDER_ARBITRARY;
+ private boolean selfSelector;
+ private String playerName;
+
+ private Predicate entityCheck = entity -> true;
+ private UUID entityUUID;
+ private EntityType type;
+
+ private EntitySelector build() {
+ return new EntitySelector(
+ maxResults, includesEntities, order, selfSelector, playerName,
+ entityCheck, entityUUID, type
+ );
+ }
+
+ // Parsing
+ private static ParserLiteral parseSelector(EntitySelectorParser selectorBuilder) {
+ return Parser.read(reader -> {
+ reader.skip(); // skip @
+ if (!reader.canRead()) {
+ throw ERROR_MISSING_SELECTOR_TYPE.createWithContext(reader);
+ }
+
+ char selectorCode = reader.read();
+ switch (selectorCode) {
+ case 'p' -> {
+ selectorBuilder.maxResults = 1;
+ selectorBuilder.includesEntities = false;
+ selectorBuilder.order = EntitySelector.ORDER_NEAREST;
+ selectorBuilder.type = EntityType.PLAYER;
+ }
+ case 'a' -> {
+ selectorBuilder.maxResults = Integer.MAX_VALUE;
+ selectorBuilder.includesEntities = false;
+ selectorBuilder.order = EntitySelector.ORDER_ARBITRARY;
+ selectorBuilder.type = EntityType.PLAYER;
+ }
+ case 'r' -> {
+ selectorBuilder.maxResults = 1;
+ selectorBuilder.includesEntities = false;
+ selectorBuilder.order = EntitySelector.ORDER_RANDOM;
+ selectorBuilder.type = EntityType.PLAYER;
+ }
+ case 's' -> {
+ selectorBuilder.maxResults = 1;
+ selectorBuilder.includesEntities = true;
+ selectorBuilder.selfSelector = true;
+ }
+ case 'e' -> {
+ selectorBuilder.maxResults = Integer.MAX_VALUE;
+ selectorBuilder.includesEntities = true;
+ selectorBuilder.order = EntitySelector.ORDER_ARBITRARY;
+ // Funnily, Minecraft checks Entity::isAlive, but Bukkit only exposes the inverse of that method
+ selectorBuilder.entityCheck = entity -> !entity.isDead();
+ }
+ case 'n' -> {
+ selectorBuilder.maxResults = 1;
+ selectorBuilder.includesEntities = true;
+ selectorBuilder.order = EntitySelector.ORDER_NEAREST;
+ selectorBuilder.entityCheck = entity -> !entity.isDead();
+ }
+ default -> {
+ // Move under @
+ reader.setCursor(reader.getCursor() - 1);
+ throw ERROR_UNKNOWN_SELECTOR_TYPE.createWithContext(reader, "@" + selectorCode);
+ }
+ }
+ }).suggests(suggestSelector);
+ }
+
+ private static ParserLiteral parseSelectorOptions(EntitySelectorParser selectorBuilder) {
+ return reader -> {
+ // TODO: Implement looping to parse these selector options
+ // I'm pretty sure it would basically reuse many other object parsers as well, so maybe do those first
+ throw new UnimplementedMethodException("Entity selectors with options are not supported");
+ };
+ }
+
+ private static ParserLiteral parseNameOrUUID(EntitySelectorParser selectorBuilder) {
+ return Parser.read(reader -> {
+ int start = reader.getCursor();
+ String input = reader.readString();
+ try {
+ // Check if this is a UUID
+ selectorBuilder.entityUUID = UUID.fromString(input);
+ selectorBuilder.includesEntities = true;
+ } catch (IllegalArgumentException ignored) {
+ // Not a valid UUID string
+ if (input.isEmpty() || input.length() > 16) {
+ // Also not a valid player name
+ reader.setCursor(start);
+ throw ERROR_INVALID_NAME_OR_UUID.createWithContext(reader);
+ }
+
+ selectorBuilder.includesEntities = false;
+ selectorBuilder.playerName = input;
+ }
+
+ selectorBuilder.maxResults = 1;
+ }).suggests(suggestName);
+ }
+
+ private static final SuggestionProvider suggestName = (context, builder) -> {
+ String remaining = builder.getRemainingLowerCase();
+
+ Bukkit.getOnlinePlayers().forEach(player -> {
+ String name = player.getName().toLowerCase();
+ if (name.startsWith(remaining)) {
+ builder.suggest(player.getName());
+ }
+ });
+ };
+ private static final SuggestionProvider suggestSelector = (context, builder) -> {
+ builder.suggest("@p", ArgumentUtilities.translatedMessage("argument.entity.selector.nearestPlayer"));
+ builder.suggest("@a", ArgumentUtilities.translatedMessage("argument.entity.selector.allPlayers"));
+ builder.suggest("@r", ArgumentUtilities.translatedMessage("argument.entity.selector.randomPlayer"));
+ builder.suggest("@s", ArgumentUtilities.translatedMessage("argument.entity.selector.self"));
+ builder.suggest("@e", ArgumentUtilities.translatedMessage("argument.entity.selector.allEntities"));
+ builder.suggest("@n", ArgumentUtilities.translatedMessage("argument.entity.selector.nearestEntity"));
+ };
+ private static final SuggestionProvider suggestNameOrSelector = (context, builder) -> {
+ suggestSelector.addSuggestions(context, builder);
+ suggestName.addSuggestions(context, builder);
+ };
+ private static final SuggestionProvider suggestOpenOptions = (context, builder) -> builder.suggest("[");
+
+ public static final Parser parser = reader -> {
+ if (!reader.canRead()) {
+ // Empty input
+ return Result.withExceptionAndSuggestions(ERROR_INVALID_NAME_OR_UUID.createWithContext(reader), reader.getCursor(), suggestNameOrSelector);
+ }
+
+ // Build our selector
+ EntitySelectorParser selectorBuilder = new EntitySelectorParser();
+ Function> conclude = Result.wrapFunctionResult(success -> selectorBuilder.build());
+
+ if (reader.peek() == '@') {
+ // Looks like selector
+ return parseSelector(selectorBuilder).getResult(reader).continueWith(
+ // Successfully read selector
+ success -> {
+ if (reader.canRead() && reader.peek() == '[') {
+ // Looks like includes selector options
+ return parseSelectorOptions(selectorBuilder).getResult(reader).continueWith(
+ // If successful, build the final selector
+ conclude
+ // Otherwise, pass original exception
+ );
+ }
+
+ // Otherwise, valid selector, but suggest opening options
+ return Result.withValueAndSuggestions(selectorBuilder.build(), reader.getCursor(), suggestOpenOptions);
+ }
+ // Otherwise pass original exception
+ );
+ }
+
+ // Looks like name/uuid
+ return parseNameOrUUID(selectorBuilder).getResult(reader).continueWith(
+ // If successful, build the final selector
+ conclude
+ // Otherwise pass original exception
+ );
+ };
+}
diff --git a/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-test-toolkit/src/main/java/dev/jorel/commandapi/arguments/IntegerRangeArgumentType.java b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-test-toolkit/src/main/java/dev/jorel/commandapi/arguments/IntegerRangeArgumentType.java
new file mode 100644
index 0000000000..6ff331711d
--- /dev/null
+++ b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-test-toolkit/src/main/java/dev/jorel/commandapi/arguments/IntegerRangeArgumentType.java
@@ -0,0 +1,145 @@
+package dev.jorel.commandapi.arguments;
+
+import com.mojang.brigadier.StringReader;
+import com.mojang.brigadier.arguments.ArgumentType;
+import com.mojang.brigadier.context.CommandContext;
+import com.mojang.brigadier.exceptions.CommandSyntaxException;
+import com.mojang.brigadier.exceptions.SimpleCommandExceptionType;
+import dev.jorel.commandapi.MockCommandSource;
+import dev.jorel.commandapi.arguments.parser.Parser;
+import dev.jorel.commandapi.arguments.parser.ParserArgument;
+import dev.jorel.commandapi.arguments.parser.ParserLiteral;
+import dev.jorel.commandapi.arguments.parser.Result;
+import dev.jorel.commandapi.wrappers.IntegerRange;
+
+import java.util.function.Function;
+import java.util.function.Predicate;
+
+public class IntegerRangeArgumentType implements ArgumentType {
+ // No internal state is necessary
+ public static final IntegerRangeArgumentType INSTANCE = new IntegerRangeArgumentType();
+
+ private IntegerRangeArgumentType() {
+
+ }
+
+ // ArgumentType implementation
+ public static final SimpleCommandExceptionType EMPTY_INPUT = new SimpleCommandExceptionType(
+ ArgumentUtilities.translatedMessage("argument.range.empty")
+ );
+ public static final SimpleCommandExceptionType RANGE_SWAPPED = new SimpleCommandExceptionType(
+ ArgumentUtilities.translatedMessage("argument.range.swapped")
+ );
+
+ private static final Predicate throwInvalidIntExceptions = exception ->
+ exception.getType().equals(CommandSyntaxException.BUILT_IN_EXCEPTIONS.readerInvalidInt());
+
+ private static final ParserLiteral rangeIndicator = ArgumentUtilities.literal("..");
+
+ private static final ParserArgument readHigh = StringReader::readInt;
+
+ private static final ParserArgument readLow = reader -> {
+ // Acts like `StringReader#readInt`, but avoids reading `..` indicator for range as part of a number
+ int start = reader.getCursor();
+
+ while (reader.canRead()) {
+ char c = reader.peek();
+ if (!(c == '-' // Negative sign
+ || (c >= '0' && c <= '9') // Digit
+ || c == '.' && !(reader.canRead(2) && reader.peek(1) == '.') // Decimal point, but not `..`
+ )) break;
+ reader.skip();
+ }
+
+ String number = reader.getString().substring(start, reader.getCursor());
+ if (number.isEmpty()) {
+ reader.setCursor(start);
+ throw CommandSyntaxException.BUILT_IN_EXCEPTIONS.readerExpectedInt().createWithContext(reader);
+ }
+ try {
+ return Integer.parseInt(number);
+ } catch (NumberFormatException ignored) {
+ reader.setCursor(start);
+ throw CommandSyntaxException.BUILT_IN_EXCEPTIONS.readerInvalidInt().createWithContext(reader, number);
+ }
+ };
+
+ public static final Parser parser = reader -> {
+ if (!reader.canRead()) {
+ return Result.withException(EMPTY_INPUT.createWithContext(reader));
+ }
+
+ Function> handleNumberReadFailure = Result.wrapFunctionResult(exception -> {
+ if (throwInvalidIntExceptions.test(exception)) {
+ // Tried to input a number, but it was not a valid int
+ throw exception;
+ }
+
+ // Nothing looking like a number was found, empty input
+ throw EMPTY_INPUT.createWithContext(reader);
+ });
+
+ int start = reader.getCursor();
+ return rangeIndicator.getResult(reader).continueWith(
+ // Input ..
+ // Try to read ..high
+ success -> readHigh.getResult(reader).continueWith(
+ // Successfully input ..high
+ Result.wrapFunctionResult(IntegerRange::integerRangeLessThanOrEq),
+ // Either input a high that was not an int, or just an empty .. input
+ handleNumberReadFailure
+ ),
+ // No range indicator yet
+ // Try to read low
+ failure -> readLow.getResult(reader).continueWith(
+ // Successfully read low
+ // Try to read low..
+ low -> rangeIndicator.getResult(reader).continueWith(
+ // Successfully read low..
+ // Try to read low..high
+ success -> readHigh.getResult(reader).continueWith(
+ // Successfully read low..high
+ Result.wrapFunctionResult(high -> {
+ if (low > high) {
+ throw RANGE_SWAPPED.createWithContext(reader);
+ }
+ return new IntegerRange(low, high);
+ }),
+ // Either input a high that was not an int, or just low..
+ Result.wrapFunctionResult(exception -> {
+ if (throwInvalidIntExceptions.test(exception)) {
+ // Tried to input low..high, but high was not an int
+ throw exception;
+ }
+
+ // Input low..
+ return IntegerRange.integerRangeGreaterThanOrEq(low);
+ })
+ ),
+ // Didn't find the range indicator
+ // Input is just low
+ Result.wrapFunctionResult(failure2 -> new IntegerRange(low, low))
+ ),
+ // Either input a low that was not an int, or just an empty input
+ handleNumberReadFailure
+ )
+ ).continueWith(
+ // If we return an IntegerRange, keep that
+ Result.wrapFunctionResult(success -> success),
+ // For some reason, Minecraft explicitly maps all exceptions to underline the start of the reader input
+ // Even something like `..1.0`, which by my intuition should underline `1.0` to show it is not an integer
+ Result.wrapFunctionResult(exception -> {
+ throw new CommandSyntaxException(exception.getType(), exception.getRawMessage(), exception.getInput(), start);
+ })
+ );
+ };
+
+ @Override
+ public IntegerRange parse(StringReader reader) throws CommandSyntaxException {
+ return parser.parse(reader);
+ }
+
+ public static IntegerRange getRange(CommandContext cmdCtx, String key) {
+ return cmdCtx.getArgument(key, IntegerRange.class);
+ }
+}
diff --git a/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-test-toolkit/src/main/java/dev/jorel/commandapi/arguments/ProfileArgumentType.java b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-test-toolkit/src/main/java/dev/jorel/commandapi/arguments/ProfileArgumentType.java
new file mode 100644
index 0000000000..4df0f28330
--- /dev/null
+++ b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-test-toolkit/src/main/java/dev/jorel/commandapi/arguments/ProfileArgumentType.java
@@ -0,0 +1,94 @@
+package dev.jorel.commandapi.arguments;
+
+import com.mojang.brigadier.StringReader;
+import com.mojang.brigadier.arguments.ArgumentType;
+import com.mojang.brigadier.context.CommandContext;
+import com.mojang.brigadier.exceptions.CommandSyntaxException;
+import com.mojang.brigadier.exceptions.SimpleCommandExceptionType;
+import com.mojang.brigadier.suggestion.Suggestions;
+import com.mojang.brigadier.suggestion.SuggestionsBuilder;
+import dev.jorel.commandapi.MockCommandSource;
+import dev.jorel.commandapi.arguments.parser.Parser;
+import dev.jorel.commandapi.arguments.parser.ParserArgument;
+import dev.jorel.commandapi.arguments.parser.Result;
+import org.bukkit.Bukkit;
+import org.bukkit.entity.Player;
+
+import java.util.*;
+import java.util.concurrent.CompletableFuture;
+
+public class ProfileArgumentType implements ArgumentType {
+ // No internal state is necessary
+ public static final ProfileArgumentType INSTANCE = new ProfileArgumentType();
+
+ private ProfileArgumentType() {
+
+ }
+
+ // ArgumentType implementation
+ @FunctionalInterface
+ public interface ProfileSelector {
+ Collection getProfiles(MockCommandSource source) throws CommandSyntaxException;
+ }
+
+ public static final SimpleCommandExceptionType ERROR_UNKNOWN_PLAYER = new SimpleCommandExceptionType(
+ ArgumentUtilities.translatedMessage("argument.player.unknown")
+ );
+
+ private static final ParserArgument readName = ArgumentUtilities.readUntilWithoutEscapeCharacter(' ');
+
+ public static final Parser parser = reader -> {
+ if (reader.canRead() && reader.peek() == '@') {
+ // Looks like reading an entity selector
+ return EntitySelectorParser.parser.getResult(reader).continueWith(
+ // successfully read an entity selector, adapt it to our profile selector
+ Result.wrapFunctionResult(entitySelector -> {
+ if (entitySelector.includesEntities()) {
+ throw EntitySelectorArgumentType.ERROR_ONLY_PLAYERS_ALLOWED.create();
+ }
+ return (ProfileSelector) source -> {
+ List players = entitySelector.findPlayers(source);
+ if (players.isEmpty()) {
+ throw EntitySelectorArgumentType.NO_PLAYERS_FOUND.create();
+ }
+
+ List profiles = new ArrayList<>(players.size());
+ for (Player player : players) {
+ profiles.add(player.getUniqueId());
+ }
+ return profiles;
+ };
+ })
+ // entity selector could not be parsed, pass error unchanged
+ );
+ }
+
+ // Looks like reading a name
+ return readName.getResult(reader).continueWith(
+ // Successfully read name, convert to profile selector
+ Result.wrapFunctionResult(name -> source -> {
+ // TODO: I'm not sure if or how this should check if offline player profiles exist
+ Player player = Bukkit.getPlayerExact(name);
+ if (player == null) {
+ throw ERROR_UNKNOWN_PLAYER.create();
+ }
+ return Collections.singleton(player.getUniqueId());
+ })
+ // Name was not parsed, pass error unchanged
+ );
+ };
+
+ @Override
+ public ProfileSelector parse(StringReader reader) throws CommandSyntaxException {
+ return parser.parse(reader);
+ }
+
+ @Override
+ public CompletableFuture listSuggestions(CommandContext context, SuggestionsBuilder builder) {
+ return EntitySelectorParser.parser.listSuggestions(context, builder);
+ }
+
+ public static Collection getProfiles(CommandContext cmdCtx, String key) throws CommandSyntaxException {
+ return cmdCtx.getArgument(key, ProfileSelector.class).getProfiles(cmdCtx.getSource());
+ }
+}
diff --git a/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-test-toolkit/src/main/java/dev/jorel/commandapi/arguments/parser/Parser.java b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-test-toolkit/src/main/java/dev/jorel/commandapi/arguments/parser/Parser.java
new file mode 100644
index 0000000000..3dbc276250
--- /dev/null
+++ b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-test-toolkit/src/main/java/dev/jorel/commandapi/arguments/parser/Parser.java
@@ -0,0 +1,111 @@
+package dev.jorel.commandapi.arguments.parser;
+
+import com.mojang.brigadier.StringReader;
+import com.mojang.brigadier.context.CommandContext;
+import com.mojang.brigadier.exceptions.CommandSyntaxException;
+import com.mojang.brigadier.suggestion.Suggestions;
+import com.mojang.brigadier.suggestion.SuggestionsBuilder;
+
+import java.util.concurrent.CompletableFuture;
+
+/**
+ * A {@link FunctionalInterface} that reads input from a {@link StringReader} and interprets it as an object.
+ *
+ * Use {@link #parse(StringReader)} or {@link #listSuggestions(CommandContext, SuggestionsBuilder)} to process input.
+ *
+ * Parsers can be directly defined by implementing the abstract method {@link #getResult(StringReader)} method. They may
+ * also be defined with a slightly different interface by implementing {@link ParserArgument#parse(StringReader)}
+ * or {@link ParserLiteral#parse(StringReader)}. If you are trying to define one of these using a lambda and Java needs
+ * extra hints to infer the lambda signature, you can use one of the following static methods:
+ *
+ *
+ * {@link #parse(Parser)}
+ * {@link #argument(ParserArgument)}
+ * {@link #read(ParserLiteral)}
+ *
+ *
+ * @param The type of object that {@link #parse(StringReader)} returns.
+ */
+@FunctionalInterface
+public interface Parser {
+ //////////////////////
+ // Define interface //
+ //////////////////////
+ /**
+ * Parses the given input. The returned {@link Result} must either have a value or an exception.
+ *
+ * @param reader The {@link StringReader} that holds the input to parse.
+ * @return A {@link Result} object holding information about the parse.
+ */
+ Result getResult(StringReader reader);
+
+ /**
+ * Parses the given input and either returns the corresponding object or throws
+ * an exception explaining why the input could not be interpreted.
+ *
+ * @param reader The {@link StringReader} that holds the input to parse.
+ * @return The object represented by the given input.
+ * @throws CommandSyntaxException If the input is malformed.
+ */
+ default T parse(StringReader reader) throws CommandSyntaxException {
+ return getResult(reader).throwOrReturn();
+ }
+
+ /**
+ * Attempts to suggest how the remaining input could be completed or fixed to produce valid input for this parser.
+ *
+ * @param context The {@link CommandContext} that holds information about the command that needs suggestions.
+ * @param builder The {@link SuggestionsBuilder} that holds the input and position where suggestions should be given.
+ * @return A {@link CompletableFuture} that holds the resulting {@link Suggestions}.
+ * @param The type of object returned by {@link CommandContext#getSource()}.
+ */
+ default CompletableFuture listSuggestions(CommandContext context, SuggestionsBuilder builder) {
+ StringReader reader = new StringReader(builder.getInput());
+ reader.setCursor(builder.getStart());
+
+ Result result = getResult(reader);
+ if (result.suggestions() == null) {
+ return builder.buildFuture();
+ }
+ builder = builder.createOffset(result.suggestionsStart());
+ result.suggestions().addSuggestions(context, builder);
+ return builder.buildFuture();
+ }
+
+ // Helper methods for letting Java infer the signature of a lambda
+
+ /**
+ * Directly returns the {@link Parser} given. This method can help Java infer
+ * the lambda signature of {@code (StringReader) -> Result}.
+ *
+ * @param parser The {@link Parser} lambda.
+ * @return The {@link Parser} object.
+ * @param The type of object that {@link #parse(StringReader)} returns.
+ */
+ static Parser parse(Parser parser) {
+ return parser;
+ }
+
+ /**
+ * Directly returns the {@link ParserArgument} given. This method can help Java infer
+ * the lambda signature of {@code (StringReader) -> T throws CommandSyntaxException}.
+ *
+ * @param parser The {@link ParserArgument} lambda.
+ * @return The {@link ParserArgument} object.
+ * @param The type of object that {@link #parse(StringReader)} returns.
+ */
+ static ParserArgument argument(ParserArgument parser) {
+ return parser;
+ }
+
+ /**
+ * Directly returns the {@link ParserLiteral} given. This method can help Java infer
+ * the lambda signature of {@code (StringReader) -> void throws CommandSyntaxException}.
+ *
+ * @param reader The {@link ParserLiteral} lambda.
+ * @return The {@link ParserLiteral} object.
+ */
+ static ParserLiteral read(ParserLiteral reader) {
+ return reader;
+ }
+}
diff --git a/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-test-toolkit/src/main/java/dev/jorel/commandapi/arguments/parser/ParserArgument.java b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-test-toolkit/src/main/java/dev/jorel/commandapi/arguments/parser/ParserArgument.java
new file mode 100644
index 0000000000..d10a4073c9
--- /dev/null
+++ b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-test-toolkit/src/main/java/dev/jorel/commandapi/arguments/parser/ParserArgument.java
@@ -0,0 +1,64 @@
+package dev.jorel.commandapi.arguments.parser;
+
+import com.mojang.brigadier.StringReader;
+import com.mojang.brigadier.exceptions.CommandSyntaxException;
+
+/**
+ * A {@link FunctionalInterface} with abstract method {@link #parse(StringReader)}.
+ * This extends {@link Parser}{@code }.
+ *
+ * @param The type of object that {@link #parse(StringReader)} returns.
+ */
+@FunctionalInterface
+public interface ParserArgument extends Parser {
+ // Implement parsing logic
+ @Override
+ T parse(StringReader reader) throws CommandSyntaxException;
+
+ @Override
+ default Result getResult(StringReader reader) {
+ try {
+ return Result.withValue(parse(reader));
+ } catch (CommandSyntaxException exception) {
+ return Result.withException(exception);
+ }
+ }
+
+ // Add suggestions
+
+ /**
+ * A {@link ParserArgument} that also places suggestions at the cursor of the {@link StringReader} when invoked.
+ *
+ * @param base The {@link ParserArgument} that defines the parsing behavior.
+ * @param suggestions The {@link SuggestionProvider} that will generate the suggestions.
+ * @param The type of object that {@link #parse(StringReader)} returns.
+ */
+ record WithSuggestions(ParserArgument base, SuggestionProvider suggestions) implements ParserArgument {
+ @Override
+ public T parse(StringReader reader) throws CommandSyntaxException {
+ return base.parse(reader);
+ }
+
+ @Override
+ public Result getResult(StringReader reader) {
+ int suggestionsStart = reader.getCursor();
+ return ParserArgument.super.getResult(reader).withSuggestions(suggestionsStart, suggestions);
+ }
+
+ @Override
+ public WithSuggestions suggests(SuggestionProvider suggestions) {
+ return new WithSuggestions<>(base, suggestions);
+ }
+ }
+
+ /**
+ * Adds suggestions to the {@link Result} of this {@link ParserArgument}. The suggestions will be placed where the
+ * cursor of the {@link StringReader} was when the parser was invoked.
+ *
+ * @param suggestions The {@link SuggestionProvider} object that will generate the suggestions.
+ * @return A {@link WithSuggestions} object to continue the building process with.
+ */
+ default WithSuggestions suggests(SuggestionProvider suggestions) {
+ return new WithSuggestions<>(this, suggestions);
+ }
+}
diff --git a/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-test-toolkit/src/main/java/dev/jorel/commandapi/arguments/parser/ParserLiteral.java b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-test-toolkit/src/main/java/dev/jorel/commandapi/arguments/parser/ParserLiteral.java
new file mode 100644
index 0000000000..6584671c47
--- /dev/null
+++ b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-test-toolkit/src/main/java/dev/jorel/commandapi/arguments/parser/ParserLiteral.java
@@ -0,0 +1,71 @@
+package dev.jorel.commandapi.arguments.parser;
+
+import com.mojang.brigadier.StringReader;
+import com.mojang.brigadier.exceptions.CommandSyntaxException;
+
+/**
+ * A {@link FunctionalInterface} with abstract method {@link #read(StringReader)}.
+ * This extends {@link Parser}{@code <}{@link Result.Void}{@code >}, so it only
+ * reads from the input {@link StringReader} and doesn't return any specific object
+ * when successful.
+ */
+@FunctionalInterface
+public interface ParserLiteral extends Parser {
+ // Implement parsing logic
+
+ /**
+ * Reads from the given input and either returns successfully or throws
+ * an exception explaining why the input could not be interpreted.
+ *
+ * @param reader The {@link StringReader} that holds the input to parse.
+ * @throws CommandSyntaxException If the input is malformed.
+ */
+ void read(StringReader reader) throws CommandSyntaxException;
+
+ @Override
+ default Result getResult(StringReader reader) {
+ try {
+ read(reader);
+ return Result.withVoidValue();
+ } catch (CommandSyntaxException exception) {
+ return Result.withException(exception);
+ }
+ }
+
+ // Add suggestions
+
+ /**
+ * A {@link ParserLiteral} that also places suggestions at the cursor of the {@link StringReader} when invoked.
+ *
+ * @param base The {@link ParserLiteral} that defines the parsing behavior.
+ * @param suggestions The {@link SuggestionProvider} that will generate the suggestions.
+ */
+ record WithSuggestions(ParserLiteral base, SuggestionProvider suggestions) implements ParserLiteral {
+ @Override
+ public void read(StringReader reader) throws CommandSyntaxException {
+ base.read(reader);
+ }
+
+ @Override
+ public Result getResult(StringReader reader) {
+ int suggestionsStart = reader.getCursor();
+ return ParserLiteral.super.getResult(reader).withSuggestions(suggestionsStart, suggestions);
+ }
+
+ @Override
+ public WithSuggestions suggests(SuggestionProvider suggestions) {
+ return new WithSuggestions(base, suggestions);
+ }
+ }
+
+ /**
+ * Adds suggestions to the {@link Result} of this {@link ParserLiteral}. The suggestions will be placed where the
+ * cursor of the {@link StringReader} was when the parser was invoked.
+ *
+ * @param suggestions The {@link SuggestionProvider} object that will generate the suggestions.
+ * @return A {@link WithSuggestions} object to continue the building process with.
+ */
+ default WithSuggestions suggests(SuggestionProvider suggestions) {
+ return new WithSuggestions(this, suggestions);
+ }
+}
diff --git a/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-test-toolkit/src/main/java/dev/jorel/commandapi/arguments/parser/Result.java b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-test-toolkit/src/main/java/dev/jorel/commandapi/arguments/parser/Result.java
new file mode 100644
index 0000000000..0b1634edca
--- /dev/null
+++ b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-test-toolkit/src/main/java/dev/jorel/commandapi/arguments/parser/Result.java
@@ -0,0 +1,242 @@
+package dev.jorel.commandapi.arguments.parser;
+
+import com.mojang.brigadier.StringReader;
+import com.mojang.brigadier.exceptions.CommandSyntaxException;
+import org.jetbrains.annotations.NotNull;
+
+import java.util.function.Function;
+
+/**
+ * Holds the information that results from a {@link Parser} parsing input. A parse either creates an object
+ * or throws a {@link CommandSyntaxException} explaining why it couldn't create its object. The result may
+ * optionally provide suggestions, which includes the point in the string where the suggestions should be
+ * placed and the {@link SuggestionProvider} that will generate the suggestions.
+ *
+ * {@link Result}s can be constructed with the following methods:
+ *
+ * {@link #withValue(Object)}
+ * {@link #withValueAndSuggestions(Object, int, SuggestionProvider)}
+ *
+ * {@link #withVoidValue()}
+ * {@link #withVoidValueAndSuggestions(int, SuggestionProvider)}
+ *
+ * {@link #withException(CommandSyntaxException)}
+ * {@link #withExceptionAndSuggestions(CommandSyntaxException, int, SuggestionProvider)}
+ *
+ * Additionally, a {@link Result} object with the same value but different
+ * suggestions can be created using {@link #withSuggestions(int, SuggestionProvider)}.
+ *
+ * The information of a {@link Result} can be accessed directly using {@link #value()}, {@link #exception()},
+ * {@link #suggestionsStart()}, or {@link #suggestions()}. Additionally, the information can be interacted with
+ * using {@link #throwOrReturn()} or {@link #continueWith(Function, Function)}.
+ *
+ * @param The type of object held as a return result.
+ * @param value The result of parsing input, if the parse was successful.
+ * @param exception The {@link CommandSyntaxException} thrown, if the parse failed.
+ * @param suggestionsStart The point in the input {@link StringReader} where the suggestions should be placed.
+ * @param suggestions The {@link SuggestionProvider} object that will generate the suggestions.
+ */
+public record Result(
+ /**
+ * @param The result of parsing input, if the parse was successful.
+ */
+ T value,
+ /**
+ * @param The {@link CommandSyntaxException} thrown, if the parse failed.
+ */
+ CommandSyntaxException exception,
+ /**
+ * @param The point in the input {@link StringReader} where the suggestions should be placed.
+ */
+ int suggestionsStart,
+ /**
+ * @param The {@link SuggestionProvider} object that will generate the suggestions.
+ */
+ SuggestionProvider suggestions
+) {
+ /**
+ * A placeholder object that represents a successful parse that didn't return a specific object.
+ * This allows a null {@link Result} value to indicate an unsuccessful parse.
+ */
+ public static class Void {
+ // Not using `java.lang.Void` since that class can never be instantiated.
+ private static final Void INSTANCE = new Void();
+
+ private Void() {
+
+ }
+ }
+
+ /**
+ * @param value The object resulting from parsing input.
+ * @param The type of object held as a return value.
+ * @return A new {@link Result} object with the given information.
+ */
+ public static Result withValue(@NotNull T value) {
+ return new Result<>(value, null, 0, null);
+ }
+
+ /**
+ * @return A new {@link Result} with a {@link Void} value.
+ */
+ public static Result withVoidValue() {
+ return withValue(Void.INSTANCE);
+ }
+
+ /**
+ * @param exception The {@link CommandSyntaxException} explaining why a value object could not be created from the given input.
+ * @param The type of object held as a return value.
+ * @return A new {@link Result} object with the given information.
+ */
+ public static Result withException(@NotNull CommandSyntaxException exception) {
+ return new Result<>(null, exception, 0, null);
+ }
+
+ /**
+ * @param value The object resulting from parsing input.
+ * @param suggestionsStart The point in the input where suggestions should be placed.
+ * @param suggestions The {@link SuggestionProvider} object that will generate the suggestions.
+ * @param The type of object held as a return value.
+ * @return A new {@link Result} object with the given information.
+ */
+ public static Result withValueAndSuggestions(@NotNull T value, int suggestionsStart, SuggestionProvider suggestions) {
+ return new Result<>(value, null, suggestionsStart, suggestions);
+ }
+
+ /**
+ * @param suggestionsStart The point in the input where suggestions should be placed.
+ * @param suggestions The {@link SuggestionProvider} object that will generate the suggestions.
+ * @return A new {@link Result} object with a {@link Void} value and the given suggestions.
+ */
+ public static Result withVoidValueAndSuggestions(int suggestionsStart, SuggestionProvider suggestions) {
+ return withValueAndSuggestions(Void.INSTANCE, suggestionsStart, suggestions);
+ }
+
+ /**
+ * @param exception The {@link CommandSyntaxException} explaining why a value object could not be created from the given input.
+ * @param suggestionsStart The point in the input where suggestions should be placed.
+ * @param suggestions The {@link SuggestionProvider} object that will generate the suggestions.
+ * @param The type of object held as a return value.
+ * @return A new {@link Result} object with the given information.
+ */
+ public static Result withExceptionAndSuggestions(@NotNull CommandSyntaxException exception, int suggestionsStart, SuggestionProvider suggestions) {
+ return new Result<>(null, exception, suggestionsStart, suggestions);
+ }
+
+ /**
+ * Keeps the same value or exception but overrides any suggestions information.
+ *
+ * @param suggestionsStart The point in the input where suggestions should be placed.
+ * @param suggestions The {@link SuggestionProvider} object that will generate the suggestions.
+ * @return A new {@link Result} object with the given information.
+ */
+ public Result withSuggestions(int suggestionsStart, SuggestionProvider suggestions) {
+ return new Result<>(this.value, this.exception, suggestionsStart, suggestions);
+ }
+
+ /**
+ * @return {@link #value()} if the parse was successful.
+ * @throws CommandSyntaxException {@link #exception()} if the parse failed.
+ */
+ public T throwOrReturn() throws CommandSyntaxException {
+ if (this.value == null) {
+ // No value, parsing failed
+ throw this.exception;
+ }
+
+ // Parsing succeeded
+ return this.value;
+ }
+
+ /**
+ * Uses this {@link Result} to generate a final {@link Result} with a value of type {@code R}.
+ * If this parse was successful, then the given {@code success} {@link Function} is used to generate the new {@link Result}.
+ * If this parse failed, then the given {@code failure} {@link Function} is used to generate the new {@link Result}.
+ *
+ * If the new {@link Result} does not have any {@link #suggestions()}, then the final {@link Result} returned by
+ * this method will have the {@link #suggestionsStart()} and {@link #suggestions()} of this {@link Result}.
+ *
+ * Note that the {@link #wrapFunctionResult(ThrowableFunction)} method may be useful for defining the {@code success}
+ * and {@code failure} {@link Function}s.
+ *
+ * @param success The {@link Function} to use to generate the new {@link Result} when this parse was successful.
+ * The argument passed to {@link Function#apply(Object)} will be the {@link #value()} of this {@link Result}.
+ * @param failure The {@link Function} to use to generate the new {@link Result} when this parse failed.
+ * The argument passed to {@link Function#apply(Object)} will be the {@link #exception()} of this {@link Result}.
+ * @return The final {@link Result} object.
+ * @param The type of object held as the {@link #value()} of the final {@link Result}.
+ */
+ public Result continueWith(Function> success, Function> failure) {
+ Result result;
+ if (this.value == null) {
+ // No value, parsing failed
+ result = failure.apply(this.exception);
+ } else {
+ // Continue with value of parse
+ result = success.apply(this.value);
+ }
+
+ // Merge suggestions
+ if (result.suggestions == null) {
+ return result.withSuggestions(suggestionsStart, suggestions);
+ }
+ return result;
+ }
+
+ /**
+ * This method works the same as {@link #continueWith(Function, Function)}, but the {@code failure} {@link Function}
+ * is given as {@code Result::withException}. This has the effect of passing the exception and suggestions of this
+ * {@link Result} without changes if this parse failed. If this parse succeeded, the {@code success} {@link Function}
+ * is applied as usual.
+ *
+ * @param success The {@link Function} to use to generate the new {@link Result} when this parse was successful.
+ * The argument passed to {@link Function#apply(Object)} will be the {@link #value()} of this {@link Result}.
+ * @return The final {@link Result} object.
+ * @param The type of object held as the {@link #value()} of the final {@link Result}.
+ */
+ public Result continueWith(Function> success) {
+ return continueWith(success, Result::withException);
+ }
+
+ /**
+ * A {@link FunctionalInterface} with abstract method {@link #apply(Object)}. Implementations of this interface
+ * can be wrapped into a {@link Function}{@code >} using {@link #wrapFunctionResult(ThrowableFunction)}.
+ *
+ * @param The type of the function parameter.
+ * @param The function return type.
+ */
+ @FunctionalInterface
+ public interface ThrowableFunction {
+ /**
+ * Applies this function to the given argument.
+ *
+ * @param arg The function argument.
+ * @return The function result.
+ * @throws CommandSyntaxException Some exception that caused this to fail.
+ */
+ R apply(T arg) throws CommandSyntaxException;
+ }
+
+ /**
+ * Converts a {@link ThrowableFunction}{@code } into a {@link Function}{@code >}.
+ * The returned {@link Function} will pass its argument unchanged to the given {@link ThrowableFunction}. The outcome
+ * of the {@link ThrowableFunction} - either returning an object of type {@code R} or throwing a {@link CommandSyntaxException}
+ * - will be wrapped into a {@link Result}{@code } as appropriate.
+ *
+ * @param original The {@link ThrowableFunction} being wrapped.
+ * @return A {@link Function} that wraps the outcome of the given {@link ThrowableFunction} in a {@link Result}.
+ * @param The type of the function parameter.
+ * @param The function return type.
+ */
+ public static Function> wrapFunctionResult(ThrowableFunction original) {
+ return arg -> {
+ R result;
+ try {
+ result = original.apply(arg);
+ } catch (CommandSyntaxException exception) {
+ return Result.withException(exception);
+ }
+ return Result.withValue(result);
+ };
+ }
+}
diff --git a/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-test-toolkit/src/main/java/dev/jorel/commandapi/arguments/parser/SuggestionProvider.java b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-test-toolkit/src/main/java/dev/jorel/commandapi/arguments/parser/SuggestionProvider.java
new file mode 100644
index 0000000000..5f134a1faf
--- /dev/null
+++ b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-test-toolkit/src/main/java/dev/jorel/commandapi/arguments/parser/SuggestionProvider.java
@@ -0,0 +1,21 @@
+package dev.jorel.commandapi.arguments.parser;
+
+import com.mojang.brigadier.context.CommandContext;
+import com.mojang.brigadier.suggestion.SuggestionsBuilder;
+
+// Note: Slightly different interface to Brigadier's SuggestionProvider
+/**
+ * A {@link FunctionalInterface} that generates suggestions for a {@link Parser}.
+ *
+ * See the {@link #addSuggestions(CommandContext, SuggestionsBuilder)} method.
+ */
+@FunctionalInterface
+public interface SuggestionProvider {
+ /**
+ * Adds suggestions to the given {@link SuggestionsBuilder}.
+ *
+ * @param context The {@link CommandContext} that holds information about the command that needs suggestions.
+ * @param builder The {@link SuggestionsBuilder} to add suggestions to.
+ */
+ void addSuggestions(CommandContext> context, SuggestionsBuilder builder);
+}
diff --git a/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-test-toolkit/src/main/java/dev/jorel/commandapi/spying/CommandAPIHandlerSpy.java b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-test-toolkit/src/main/java/dev/jorel/commandapi/spying/CommandAPIHandlerSpy.java
new file mode 100644
index 0000000000..59bbcb47c0
--- /dev/null
+++ b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-test-toolkit/src/main/java/dev/jorel/commandapi/spying/CommandAPIHandlerSpy.java
@@ -0,0 +1,64 @@
+package dev.jorel.commandapi.spying;
+
+import com.mojang.brigadier.Command;
+import com.mojang.brigadier.exceptions.CommandSyntaxException;
+import dev.jorel.commandapi.CommandAPIExecutor;
+import dev.jorel.commandapi.CommandAPIHandler;
+import dev.jorel.commandapi.MockCommandSource;
+import dev.jorel.commandapi.arguments.AbstractArgument;
+import dev.jorel.commandapi.arguments.Argument;
+import dev.jorel.commandapi.commandsenders.AbstractCommandSender;
+import org.bukkit.command.CommandSender;
+import org.mockito.Mockito;
+
+import static org.mockito.ArgumentMatchers.any;
+
+public class CommandAPIHandlerSpy {
+ // Handler instances
+ private final CommandAPIHandler, CommandSender, MockCommandSource> handler;
+ private final CommandAPIHandler, CommandSender, MockCommandSource> spyHandler;
+
+ public CommandAPIHandler, CommandSender, MockCommandSource> spyHandler() {
+ return spyHandler;
+ }
+
+ // Methods for handling intercepts
+ ExecutionQueue executionQueue = new ExecutionQueue();
+
+ public ExecutionQueue getExecutionQueue() {
+ return executionQueue;
+ }
+
+ // Setup
+ public CommandAPIHandlerSpy(CommandAPIHandler, ?, ?> commandAPIHandler) {
+ handler = (CommandAPIHandler, CommandSender, MockCommandSource>) commandAPIHandler;
+ spyHandler = Mockito.spy(handler);
+
+ Mockito.when(spyHandler.generateCommand(any(), any(), any(Boolean.class) /* Class gives non-null un-boxable default */))
+ .thenAnswer(i -> generateCommand(i.getArgument(0), i.getArgument(1), i.getArgument(2)));
+ }
+
+ // Intercepted methods
+ private Command generateCommand(
+ AbstractArgument, ?, ?, ?>[] args, // Using AbstractArgument[] because that's the actual runtime type of the array
+ CommandAPIExecutor> executor,
+ boolean converted
+ ) {
+ CommandAPIExecutor> spyExecutor = Mockito.spy(executor);
+
+ try {
+ // Not using Mockito.when to avoid calling real executes method
+ Mockito.doAnswer(i -> {
+ executionQueue.add(i.getArgument(0));
+ return i.callRealMethod();
+ }).when(spyExecutor).execute(any());
+ } catch (CommandSyntaxException ignored) {
+ // `spyExecutor#execute` will never actually throw an exception
+ }
+
+ // Convert array to Argument>[], which is what we actually want
+ Argument>[] arguments = new Argument[args.length];
+ System.arraycopy(args, 0, arguments, 0, args.length);
+ return handler.generateCommand(arguments, spyExecutor, converted);
+ }
+}
diff --git a/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-test-toolkit/src/main/java/dev/jorel/commandapi/spying/ExecutionQueue.java b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-test-toolkit/src/main/java/dev/jorel/commandapi/spying/ExecutionQueue.java
new file mode 100644
index 0000000000..ddaabdef28
--- /dev/null
+++ b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-test-toolkit/src/main/java/dev/jorel/commandapi/spying/ExecutionQueue.java
@@ -0,0 +1,31 @@
+package dev.jorel.commandapi.spying;
+
+import dev.jorel.commandapi.commandsenders.AbstractCommandSender;
+import dev.jorel.commandapi.executors.ExecutionInfo;
+import org.bukkit.command.CommandSender;
+import org.opentest4j.AssertionFailedError;
+
+import java.util.LinkedList;
+import java.util.Queue;
+
+public class ExecutionQueue {
+ Queue>> queue = new LinkedList<>();
+
+ public void clear() {
+ queue.clear();
+ }
+
+ public void add(ExecutionInfo> info) {
+ queue.add(info);
+ }
+
+ public ExecutionInfo> poll() {
+ return queue.poll();
+ }
+
+ public void assertNoMoreCommandsWereRun() {
+ if (!queue.isEmpty()) {
+ throw new AssertionFailedError("Expected no more commands to be run, but found " + queue.size() + " command(s) left");
+ }
+ }
+}
diff --git a/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-test-toolkit/src/main/resources/plugin.yml b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-test-toolkit/src/main/resources/plugin.yml
new file mode 100644
index 0000000000..be876a41cb
--- /dev/null
+++ b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-test-toolkit/src/main/resources/plugin.yml
@@ -0,0 +1,6 @@
+name: CommandAPI
+main: dev.jorel.commandapi.MockCommandAPIPlugin
+version: ${project.version}
+author: Will Kroboth
+website: https://www.jorel.dev/CommandAPI/
+api-version: 1.13
\ No newline at end of file
diff --git a/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-test-toolkit/src/test/java/dev/jorel/commandapi/AssertArgumentUtilitiesTests.java b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-test-toolkit/src/test/java/dev/jorel/commandapi/AssertArgumentUtilitiesTests.java
new file mode 100644
index 0000000000..09bfdbb2f5
--- /dev/null
+++ b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-test-toolkit/src/test/java/dev/jorel/commandapi/AssertArgumentUtilitiesTests.java
@@ -0,0 +1,275 @@
+package dev.jorel.commandapi;
+
+import be.seeseemelk.mockbukkit.entity.PlayerMock;
+import dev.jorel.commandapi.arguments.BooleanArgument;
+import dev.jorel.commandapi.arguments.IntegerArgument;
+import dev.jorel.commandapi.arguments.StringArgument;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.util.Map;
+
+class AssertArgumentUtilitiesTests extends CommandTestBase {
+ // Setup
+ private PlayerMock player;
+
+ @BeforeEach
+ public void setUp() {
+ super.setUp();
+
+ new CommandAPICommand("test")
+ .withArguments(
+ new IntegerArgument("int"),
+ new StringArgument("string"),
+ new BooleanArgument("bool")
+ )
+ .withOptionalArguments(new BooleanArgument("shouldFail"))
+ .executes(info -> {
+ if (info.args().getOrDefaultUnchecked("shouldFail", false)) {
+ throw CommandAPI.failWithString("Command failed");
+ }
+ })
+ .register();
+
+ player = server.addPlayer();
+ }
+
+ @AfterEach
+ public void tearDown() {
+ super.tearDown();
+ }
+
+ // Tests
+ @Test
+ void testSuccessArgumentCheck() {
+ assertCommandSucceedsWithArguments(
+ player, "test 10 hello true",
+ Map.of(
+ "int", 10,
+ "string", "hello",
+ "bool", true
+ )
+ );
+
+ assertCommandFailsWithArguments(
+ player, "test 10 hello true true", "Command failed",
+ Map.of(
+ "int", 10,
+ "string", "hello",
+ "bool", true,
+ "shouldFail", true
+ )
+ );
+
+ assertCommandSucceedsWithArguments(
+ player, "test 10 hello true",
+ 10, "hello", true
+ );
+
+ assertCommandFailsWithArguments(
+ player, "test 10 hello true true", "Command failed",
+ 10, "hello", true, true
+ );
+ }
+
+ @Test
+ void testSuccessMapOrderDoesNotMatter() {
+ assertCommandSucceedsWithArguments(
+ player, "test 10 hello true",
+ Map.of(
+ "bool", true,
+ "int", 10,
+ "string", "hello"
+ )
+ );
+
+ assertCommandFailsWithArguments(
+ player, "test 10 hello true true", "Command failed",
+ Map.of(
+ "bool", true,
+ "int", 10,
+ "shouldFail", true,
+ "string", "hello"
+ )
+ );
+ }
+
+ @Test
+ void testFailureInvalidCommand() {
+ assertAssertionFails(
+ () -> assertCommandSucceedsWithArguments(
+ player, "invalid 10 hello true",
+ Map.of(
+ "int", 10,
+ "string", "hello",
+ "bool", true
+ )
+ ),
+ "Expected command dispatch to succeed " +
+ "==> Unexpected exception thrown: " +
+ "com.mojang.brigadier.exceptions.CommandSyntaxException: Unknown command at position 0: <--[HERE]"
+ );
+ assertAssertionFails(
+ () -> assertCommandFailsWithArguments(
+ player, "invalid 10 hello true true", "Command failed",
+ Map.of(
+ "int", 10,
+ "string", "hello",
+ "bool", true,
+ "shouldFail", true
+ )
+ ),
+ "Expected command dispatch to fail with message , " +
+ "but got "
+ );
+
+ assertAssertionFails(
+ () -> assertCommandSucceedsWithArguments(
+ player, "invalid 10 hello true",
+ 10, "hello", true
+ ),
+ "Expected command dispatch to succeed " +
+ "==> Unexpected exception thrown: " +
+ "com.mojang.brigadier.exceptions.CommandSyntaxException: Unknown command at position 0: <--[HERE]"
+ );
+ assertAssertionFails(
+ () -> assertCommandFailsWithArguments(
+ player, "invalid 10 hello true true", "Command failed",
+ 10, "hello", true, true
+ ),
+ "Expected command dispatch to fail with message , " +
+ "but got "
+ );
+ }
+
+ @Test
+ void testFailureWrongMapArguments() {
+ // Argument missing
+ final Map missingArgument = Map.of(
+ "string", "hello",
+ "bool", true
+ );
+
+ assertAssertionFails(
+ () -> assertCommandSucceedsWithArguments(
+ player, "test 10 hello true",
+ missingArgument
+ ),
+ "Argument maps are not equal " +
+ // The order of keys from Map.of is not guaranteed, so we don't know its String beforehand
+ "==> expected: <" + missingArgument + "> but was: <{int=10, string=hello, bool=true}>"
+ );
+ final Map missingArgumentFails = Map.of(
+ "string", "hello",
+ "bool", true,
+ "shouldFail", true
+ );
+ assertAssertionFails(
+ () -> assertCommandFailsWithArguments(
+ player, "test 10 hello true true", "Command failed",
+ missingArgumentFails
+ ),
+ "Argument maps are not equal " +
+ // The order of keys from Map.of is not guaranteed, so we don't know its String beforehand
+ "==> expected: <" + missingArgumentFails + "> but was: <{int=10, string=hello, bool=true, shouldFail=true}>"
+ );
+
+ // Argument wrong key
+ final Map wrongKey = Map.of(
+ "number", 10,
+ "string", "hello",
+ "bool", true
+ );
+ assertAssertionFails(
+ () -> assertCommandSucceedsWithArguments(
+ player, "test 10 hello true",
+ wrongKey
+ ),
+ "Argument maps are not equal " +
+ "==> expected: <" + wrongKey + "> but was: <{int=10, string=hello, bool=true}>"
+ );
+ final Map wrongKeyFails = Map.of(
+ "number", 10,
+ "string", "hello",
+ "bool", true,
+ "shouldFail", true
+ );
+ assertAssertionFails(
+ () -> assertCommandFailsWithArguments(
+ player, "test 10 hello true true", "Command failed",
+ wrongKeyFails
+ ),
+ "Argument maps are not equal " +
+ "==> expected: <" + wrongKeyFails + "> but was: <{int=10, string=hello, bool=true, shouldFail=true}>"
+ );
+
+ // Argument wrong value
+ Map wrongValue = Map.of(
+ "int", 5,
+ "string", "hello",
+ "bool", true
+ );
+ assertAssertionFails(
+ () -> assertCommandSucceedsWithArguments(
+ player, "test 10 hello true",
+ wrongValue
+ ),
+ "Argument maps are not equal " +
+ "==> expected: <" + wrongValue + "> but was: <{int=10, string=hello, bool=true}>"
+ );
+ Map wrongValueFails = Map.of(
+ "int", 5,
+ "string", "hello",
+ "bool", true,
+ "shouldFail", true
+ );
+ assertAssertionFails(
+ () -> assertCommandFailsWithArguments(
+ player, "test 10 hello true true", "Command failed",
+ wrongValueFails
+ ),
+ "Argument maps are not equal " +
+ "==> expected: <" + wrongValueFails + "> but was: <{int=10, string=hello, bool=true, shouldFail=true}>"
+ );
+ }
+
+ @Test
+ void testFailureWrongArrayArguments() {
+ // Argument missing
+ assertAssertionFails(
+ () -> assertCommandSucceedsWithArguments(
+ player, "test 10 hello true",
+ "hello", true
+ ),
+ "Argument arrays are not equal ==> " +
+ "array lengths differ, expected: <2> but was: <3>"
+ );
+ assertAssertionFails(
+ () -> assertCommandFailsWithArguments(
+ player, "test 10 hello true true", "Command failed",
+ "hello", true, true
+ ),
+ "Argument arrays are not equal ==> " +
+ "array lengths differ, expected: <3> but was: <4>"
+ );
+
+ // Arguments out of order
+ assertAssertionFails(
+ () -> assertCommandSucceedsWithArguments(
+ player, "test 10 hello true",
+ true, 10, "hello"
+ ),
+ "Argument arrays are not equal ==> " +
+ "array contents differ at index [0], expected: but was: <10>"
+ );
+ assertAssertionFails(
+ () -> assertCommandFailsWithArguments(
+ player, "test 10 hello true true", "Command failed",
+ true, 10, "hello", true
+ ),
+ "Argument arrays are not equal ==> " +
+ "array contents differ at index [0], expected: but was: <10>"
+ );
+ }
+}
diff --git a/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-test-toolkit/src/test/java/dev/jorel/commandapi/AssertSuggestionUtilitiesTest.java b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-test-toolkit/src/test/java/dev/jorel/commandapi/AssertSuggestionUtilitiesTest.java
new file mode 100644
index 0000000000..faefeb9e74
--- /dev/null
+++ b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-test-toolkit/src/test/java/dev/jorel/commandapi/AssertSuggestionUtilitiesTest.java
@@ -0,0 +1,195 @@
+package dev.jorel.commandapi;
+
+import be.seeseemelk.mockbukkit.entity.PlayerMock;
+import com.mojang.brigadier.suggestion.Suggestion;
+import com.mojang.brigadier.suggestion.SuggestionsBuilder;
+import dev.jorel.commandapi.arguments.ArgumentSuggestions;
+import dev.jorel.commandapi.arguments.StringArgument;
+import org.bukkit.command.CommandSender;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import java.util.List;
+
+import static dev.jorel.commandapi.StringTooltip.ofString;
+
+class AssertSuggestionUtilitiesTest extends CommandTestBase {
+ // Setup
+ private PlayerMock player;
+
+ @BeforeEach
+ public void setUp() {
+ super.setUp();
+
+ new CommandAPICommand("test")
+ .withArguments(new StringArgument("string").replaceSuggestions(ArgumentSuggestions.stringsWithTooltips(
+ ofString("A", "First letter"),
+ ofString("B", "Second letter"),
+ ofString("C", "Third letter")
+ )))
+ .executes(DEFAULT_EXECUTOR)
+ .register();
+
+ player = server.addPlayer();
+ }
+
+ @AfterEach
+ public void tearDown() {
+ super.tearDown();
+ }
+
+ private void useList(boolean useList, CommandSender sender, String command, String... suggestions) {
+ if (useList) {
+ assertCommandSuggests(sender, command, List.of(suggestions));
+ } else {
+ assertCommandSuggests(sender, command, suggestions);
+ }
+ }
+
+ private void useListTooltips(boolean useList, CommandSender sender, String command, Suggestion... suggestions) {
+ if (useList) {
+ assertCommandSuggestsTooltips(sender, command, List.of(suggestions));
+ } else {
+ assertCommandSuggestsTooltips(sender, command, suggestions);
+ }
+ }
+
+ // Tests
+ @ParameterizedTest
+ @ValueSource(booleans = {false, true})
+ void testSuccessfulAssertCommandSuggests(boolean useList) {
+ useList(useList, player, "test ", "A", "B", "C");
+ }
+
+ @ParameterizedTest
+ @ValueSource(booleans = {false, true})
+ void testSuccessfulAssertCommandSuggestsTooltips(boolean useList) {
+ useListTooltips(
+ useList, player, "test ",
+ makeTooltip("A", "First letter"),
+ makeTooltip("B", "Second letter"),
+ makeTooltip("C", "Third letter")
+ );
+ }
+
+ @ParameterizedTest
+ @ValueSource(booleans = {true, false})
+ void testUnsuccessfulAssertCommandSuggests(boolean useList) {
+ // Invalid command
+ assertAssertionFails(
+ () -> useList(useList, player, "invalid ", "A", "B", "C"),
+ "Suggestions did not match ==> expected: <[A, B, C]> but was: <[]>"
+ );
+
+ // Wrong number of suggestions
+ assertAssertionFails(
+ () -> useList(useList, player, "test ", "A", "B"),
+ "Suggestions did not match ==> expected: <[A, B]> but was: <[A, B, C]>"
+ );
+ assertAssertionFails(
+ () -> useList(useList, player, "test ", "A", "B", "C", "D"),
+ "Suggestions did not match ==> expected: <[A, B, C, D]> but was: <[A, B, C]>"
+ );
+
+ // Wrong suggestion order
+ assertAssertionFails(
+ () -> useList(useList, player, "test ", "B", "C", "A"),
+ "Suggestions did not match ==> expected: <[B, C, A]> but was: <[A, B, C]>"
+ );
+
+ // Wrong text
+ assertAssertionFails(
+ () -> useList(useList, player, "test ", "a", "B", "C"),
+ "Suggestions did not match ==> expected: <[a, B, C]> but was: <[A, B, C]>"
+ );
+ }
+
+ @ParameterizedTest
+ @ValueSource(booleans = {true, false})
+ void testUnsuccessfulAssertCommandSuggestsTooltips(boolean useList) {
+ // Invalid command
+ assertAssertionFails(
+ () -> useListTooltips(
+ useList, player, "invalid ",
+ makeTooltip("A", "First letter"),
+ makeTooltip("B", "Second letter"),
+ makeTooltip("C", "Third letter")
+ ),
+ "Suggestions did not match ==> expected: <[A, B, C]> but was: <[]>"
+ );
+
+ // Wrong number of suggestions
+ assertAssertionFails(
+ () -> useListTooltips(
+ useList, player, "test ",
+ makeTooltip("A", "First letter"),
+ makeTooltip("B", "Second letter")
+ ),
+ "Suggestions did not match ==> expected: <[A, B]> but was: <[A, B, C]>"
+ );
+ assertAssertionFails(
+ () -> useListTooltips(
+ useList, player, "test ",
+ makeTooltip("A", "First letter"),
+ makeTooltip("B", "Second letter"),
+ makeTooltip("C", "Third letter"),
+ makeTooltip("D", "Fourth letter")
+ ),
+ "Suggestions did not match ==> expected: <[A, B, C, D]> but was: <[A, B, C]>"
+ );
+
+ // Wrong suggestion order
+ assertAssertionFails(
+ () -> useListTooltips(
+ useList, player, "test ",
+ makeTooltip("B", "Second letter"),
+ makeTooltip("C", "Third letter"),
+ makeTooltip("A", "First letter")
+ ),
+ "Suggestions did not match ==> expected: <[B, C, A]> but was: <[A, B, C]>"
+ );
+
+ // Wrong text
+ assertAssertionFails(
+ () -> useListTooltips(
+ useList, player, "test ",
+ makeTooltip("a", "First letter"),
+ makeTooltip("B", "Second letter"),
+ makeTooltip("C", "Third letter")
+ ),
+ "Suggestions did not match ==> expected: <[a, B, C]> but was: <[A, B, C]>"
+ );
+
+ // Wrong tooltip
+ assertAssertionFails(
+ () -> useListTooltips(
+ useList, player, "test ",
+ makeTooltip("A", "First"),
+ makeTooltip("B", "Second letter"),
+ makeTooltip("C", "Third letter")
+ ),
+ "Tooltips did not match " +
+ "==> expected: <[First, Second letter, Third letter]> but was: <[First letter, Second letter, Third letter]>"
+ );
+ }
+
+ @Test
+ void testAssertSuggestionsStartAt() {
+ SuggestionsBuilder builder = new SuggestionsBuilder("abc", 1);
+ builder.suggest("bcd");
+ List suggestions = builder.build().getList();
+
+ // Successful
+ assertSuggestionsStartAt(1, suggestions);
+
+ // Unsuccessful
+ assertAssertionFails(
+ () -> assertSuggestionsStartAt(0, suggestions),
+ "Suggestion #0 " +
+ "started at wrong index ==> expected: <0> but was: <1>"
+ );
+ }
+}
diff --git a/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-test-toolkit/src/test/java/dev/jorel/commandapi/CommandAPIVersionHandlerOverridingTests.java b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-test-toolkit/src/test/java/dev/jorel/commandapi/CommandAPIVersionHandlerOverridingTests.java
new file mode 100644
index 0000000000..4477ea787c
--- /dev/null
+++ b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-test-toolkit/src/test/java/dev/jorel/commandapi/CommandAPIVersionHandlerOverridingTests.java
@@ -0,0 +1,58 @@
+package dev.jorel.commandapi;
+
+import be.seeseemelk.mockbukkit.MockBukkit;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+/**
+ * Tests for using {@link CommandAPIVersionHandler#usePlatformImplementation(CommandAPIPlatform)}
+ */
+class CommandAPIVersionHandlerOverridingTests {
+ // Setup
+ @BeforeEach
+ public void setUp() {
+ MockBukkit.mock();
+ }
+
+ @AfterEach
+ public void tearDown() {
+ MockBukkit.unmock();
+ }
+
+ private static class CustomMockCommandAPIBukkit extends MockCommandAPIBukkit {
+
+ }
+
+ // Tests
+ @Test
+ void testDefaultPlatform() {
+ MockCommandAPIPlugin.load();
+
+ assertEquals(MockCommandAPIBukkit.class, CommandAPITestUtilities.getCommandAPIPlatform().getClass());
+ }
+
+ @Test
+ void testChangingPlatform() {
+ CommandAPIVersionHandler.usePlatformImplementation(new CustomMockCommandAPIBukkit());
+ MockCommandAPIPlugin.load();
+
+ assertEquals(CustomMockCommandAPIBukkit.class, CommandAPITestUtilities.getCommandAPIPlatform().getClass());
+ }
+
+ @Test
+ void testPlatformDoesNotPersist() {
+ CommandAPIVersionHandler.usePlatformImplementation(new CustomMockCommandAPIBukkit());
+ MockCommandAPIPlugin.load();
+
+ assertEquals(CustomMockCommandAPIBukkit.class, CommandAPITestUtilities.getCommandAPIPlatform().getClass());
+
+ MockBukkit.unmock();
+ MockBukkit.mock();
+
+ MockCommandAPIPlugin.load();
+ assertEquals(MockCommandAPIBukkit.class, CommandAPITestUtilities.getCommandAPIPlatform().getClass());
+ }
+}
diff --git a/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-test-toolkit/src/test/java/dev/jorel/commandapi/CommandTestBase.java b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-test-toolkit/src/test/java/dev/jorel/commandapi/CommandTestBase.java
new file mode 100644
index 0000000000..2c292a3cdd
--- /dev/null
+++ b/commandapi-platforms/commandapi-bukkit/commandapi-bukkit-test-toolkit/src/test/java/dev/jorel/commandapi/CommandTestBase.java
@@ -0,0 +1,80 @@
+package dev.jorel.commandapi;
+
+import be.seeseemelk.mockbukkit.MockBukkit;
+import be.seeseemelk.mockbukkit.ServerMock;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
+import com.mojang.brigadier.CommandDispatcher;
+import com.mojang.brigadier.StringReader;
+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 dev.jorel.commandapi.arguments.parser.Parser;
+import dev.jorel.commandapi.arguments.parser.ParserArgument;
+import dev.jorel.commandapi.executors.CommandExecutionInfo;
+import org.bukkit.command.CommandSender;
+import org.junit.jupiter.api.function.Executable;
+import org.opentest4j.AssertionFailedError;
+
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+public abstract class CommandTestBase extends CommandAPITestUtilities {
+ // Useful objects
+ public static CommandExecutionInfo DEFAULT_EXECUTOR = info -> {};
+
+ // Setup
+ protected ServerMock server;
+ protected MockCommandAPIPlugin plugin;
+
+ public void setUp() {
+ server = MockBukkit.mock();
+ plugin = MockBukkit.load(MockCommandAPIPlugin.class);
+ }
+
+ public void tearDown() {
+ MockBukkit.unmock();
+ }
+
+ // Additional test utilities
+ @CanIgnoreReturnValue
+ public T assertThrowsWithMessage(Class exceptionClass, Executable executable, String expectedMessage) {
+ T exception = assertThrows(exceptionClass, executable);
+ String actualMessage = exception.getMessage();
+ if (!expectedMessage.equals(actualMessage)) {
+ throw new AssertionFailedError(
+ "Exception messages did not match. Expected <" + expectedMessage + "> but was <" + actualMessage + ">.",
+ expectedMessage, actualMessage, exception
+ );
+ }
+ return exception;
+ }
+
+ @CanIgnoreReturnValue
+ public AssertionFailedError assertAssertionFails(Executable executable, String expectedMessage) {
+ return assertThrowsWithMessage(AssertionFailedError.class, executable, expectedMessage);
+ }
+
+ public CommandContext createContextWithParser(
+ CommandSender source, String key, Parser parser, String input
+ ) throws CommandSyntaxException {
+ CommandDispatcher dispatcher = getCommandAPIPlatform().getBrigadierDispatcher();
+ CommandContextBuilder contextBuilder = new CommandContextBuilder<>(
+ dispatcher,
+ new MockCommandSource(source),
+ dispatcher.getRoot(),
+ 0
+ );
+
+ StringReader reader = new StringReader(input);
+ T result = parser.parse(reader);
+ contextBuilder.withArgument(key, new ParsedArgument<>(0, reader.getCursor(), result));
+
+ return contextBuilder.build(input);
+ }
+
+ public CommandContext createContextWithParser(
+ CommandSender source, String key, ParserArgument