Skip to content

Commit 41c9c4d

Browse files
committed
feat: implement suspendable command argument parsing with custom arguments
1 parent 7a7c9ce commit 41c9c4d

File tree

4 files changed

+206
-5
lines changed

4 files changed

+206
-5
lines changed
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
package dev.slne.surf.surfapi.bukkit.api.command.args
2+
3+
import com.github.shynixn.mccoroutine.folia.SuspendingPlugin
4+
import com.github.shynixn.mccoroutine.folia.scope
5+
import com.mojang.brigadier.context.CommandContext
6+
import dev.jorel.commandapi.CommandAPIBukkit
7+
import dev.jorel.commandapi.CommandAPIHandler
8+
import dev.jorel.commandapi.arguments.Argument
9+
import dev.jorel.commandapi.arguments.CustomArgument
10+
import dev.jorel.commandapi.executors.CommandArguments
11+
import dev.slne.surf.surfapi.bukkit.api.util.getCallingPlugin
12+
import kotlinx.coroutines.CoroutineScope
13+
import kotlinx.coroutines.Deferred
14+
import kotlinx.coroutines.async
15+
16+
/**
17+
* Base class for custom CommandAPI arguments whose parsing logic is executed asynchronously
18+
* using Kotlin coroutines.
19+
*
20+
* This abstraction allows implementing suspendable parsing logic while still integrating
21+
* with CommandAPI's synchronous argument parsing pipeline by returning a [Deferred].
22+
*
23+
* @param T The final parsed argument type.
24+
* @param B The base argument type produced by the underlying [Argument].
25+
* @property base The underlying base argument used for initial parsing.
26+
* @property scope The [CoroutineScope] used to execute the suspendable parsing logic.
27+
*/
28+
abstract class SuspendCustomArgument<T, B>(
29+
private val base: Argument<B>,
30+
private val scope: CoroutineScope = extractCallingPluginScopeOrThrow()
31+
) : CustomArgument<Deferred<T>, B>(base, { _ ->
32+
throw UnsupportedOperationException("Parsing is performed asynchronously via the overridden parse method")
33+
}) {
34+
35+
/**
36+
* Parses the argument asynchronously.
37+
*
38+
* This method is executed inside the provided [CoroutineScope] and may perform
39+
* suspendable or asynchronous operations such as database lookups or network calls.
40+
*
41+
* Implementations should throw [CustomArgumentException] to signal a user-facing
42+
* parsing error.
43+
*
44+
* @param info Contextual information about the argument being parsed.
45+
* @return The parsed argument value.
46+
* @throws CustomArgumentException If parsing fails with a user-facing error.
47+
*/
48+
abstract suspend fun CoroutineScope.parse(info: CustomArgumentInfo<B>): T
49+
50+
/**
51+
* Synchronously invoked by CommandAPI to parse the argument.
52+
*
53+
* Internally, this method delegates to [parse] by launching a coroutine in [scope]
54+
* and returning a [Deferred] representing the eventual parsing result.
55+
*
56+
* Any thrown [CustomArgumentException] is converted into a Brigadier
57+
* [com.mojang.brigadier.exceptions.CommandSyntaxException].
58+
*
59+
* @param cmdCtx The Brigadier command context.
60+
* @param key The argument node name.
61+
* @param previousArgs Previously parsed command arguments.
62+
* @return A [Deferred] that completes with the parsed argument value.
63+
*/
64+
final override fun <CommandSourceStack : Any> parseArgument(
65+
cmdCtx: CommandContext<CommandSourceStack>,
66+
key: String,
67+
previousArgs: CommandArguments
68+
): Deferred<T> {
69+
val customResult = CommandAPIHandler.getRawArgumentInput(cmdCtx, key)
70+
val parsedInput = base.parseArgument(cmdCtx, key, previousArgs)
71+
72+
return scope.async {
73+
try {
74+
val sender = CommandAPIBukkit.get<CommandSourceStack>()
75+
.getCommandSenderFromCommandSource(cmdCtx.source).source
76+
val info = CustomArgumentInfo(sender, previousArgs, customResult, parsedInput)
77+
parse(info)
78+
} catch (e: CustomArgumentException) {
79+
throw e.toCommandSyntax(customResult, cmdCtx)
80+
}
81+
}
82+
}
83+
84+
companion object {
85+
/**
86+
* Extracts the [CoroutineScope] of the calling plugin.
87+
*
88+
* The calling plugin must implement [SuspendingPlugin]. If it does not,
89+
* an exception is thrown to prevent executing coroutines without
90+
* proper lifecycle management.
91+
*
92+
* @return The plugin's coroutine scope.
93+
* @throws IllegalArgumentException If the plugin does not implement [SuspendingPlugin].
94+
*/
95+
private fun extractCallingPluginScopeOrThrow(): CoroutineScope {
96+
val plugin = getCallingPlugin(2)
97+
98+
require(plugin is SuspendingPlugin) {
99+
"Failed to extract CoroutineScope: plugin '${plugin.name}' does not implement SuspendingPlugin. " +
100+
"Provide a CoroutineScope explicitly or use a SuspendingPlugin."
101+
}
102+
103+
return plugin.scope
104+
}
105+
}
106+
}
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,16 @@
11
package dev.slne.surf.surfapi.bukkit.test.command.subcommands
22

33
import dev.jorel.commandapi.CommandAPICommand
4-
import dev.slne.surf.surfapi.bukkit.test.command.subcommands.suspendexecution.SuspendCommandExecutionCancellationExceptionTest
5-
import dev.slne.surf.surfapi.bukkit.test.command.subcommands.suspendexecution.SuspendCommandExecutionDelayTest
6-
import dev.slne.surf.surfapi.bukkit.test.command.subcommands.suspendexecution.SuspendCommandExecutionSyntaxExceptionTest
7-
import dev.slne.surf.surfapi.bukkit.test.command.subcommands.suspendexecution.SuspendCommandExecutionUncheckedExceptionTest
4+
import dev.slne.surf.surfapi.bukkit.test.command.subcommands.suspendexecution.*
85

96
class SuspendCommandExecutionTest(name: String) : CommandAPICommand(name) {
107
init {
118
withSubcommands(
129
SuspendCommandExecutionDelayTest("delay"),
1310
SuspendCommandExecutionSyntaxExceptionTest("syntaxException"),
1411
SuspendCommandExecutionUncheckedExceptionTest("uncheckedException"),
15-
SuspendCommandExecutionCancellationExceptionTest("cancellationException")
12+
SuspendCommandExecutionCancellationExceptionTest("cancellationException"),
13+
SuspendCommandExecutionDelayWithSuspendArgTest("delayWithSuspendArg")
1614
)
1715
}
1816
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
package dev.slne.surf.surfapi.bukkit.test.command.subcommands.suspendexecution
2+
3+
import dev.jorel.commandapi.CommandAPICommand
4+
import dev.jorel.commandapi.arguments.StringArgument
5+
import dev.jorel.commandapi.kotlindsl.arguments
6+
import dev.slne.surf.surfapi.bukkit.api.command.args.SuspendCustomArgument
7+
import dev.slne.surf.surfapi.bukkit.api.command.executors.anyExecutorSuspend
8+
import dev.slne.surf.surfapi.core.api.command.args.awaiting
9+
import dev.slne.surf.surfapi.core.api.messages.adventure.sendText
10+
import kotlinx.coroutines.CoroutineScope
11+
import kotlinx.coroutines.delay
12+
import org.bukkit.Bukkit
13+
import org.bukkit.entity.Player
14+
import kotlin.time.Duration.Companion.seconds
15+
16+
class SuspendCommandExecutionDelayWithSuspendArgTest(name: String) : CommandAPICommand(name) {
17+
init {
18+
arguments(SuspendPlayerArgument("player"))
19+
20+
anyExecutorSuspend { sender, arguments ->
21+
val player = arguments.awaiting<Player>("player")
22+
23+
sender.sendText {
24+
appendPrefix()
25+
info("Delay 3 seconds...")
26+
}
27+
delay(3.seconds)
28+
sender.sendText {
29+
appendPrefix()
30+
info("Done!")
31+
appendSpace()
32+
info("Player: ")
33+
append(player.displayName())
34+
}
35+
}
36+
}
37+
38+
class SuspendPlayerArgument(nodeName: String) :
39+
SuspendCustomArgument<Player, String>(StringArgument(nodeName)) {
40+
override suspend fun CoroutineScope.parse(info: CustomArgumentInfo<String>): Player {
41+
info.sender.sendText {
42+
appendPrefix()
43+
info("Delaying in command parsing")
44+
}
45+
delay(2.seconds)
46+
val playerName = info.input()
47+
48+
info.sender.sendText {
49+
appendPrefix()
50+
info("Done delaying in command parsing")
51+
}
52+
53+
val player =
54+
Bukkit.getPlayer(playerName) ?: throw CustomArgumentException.fromMessageBuilder(
55+
MessageBuilder()
56+
.append("Player ")
57+
.appendArgInput()
58+
.append(" not found!")
59+
)
60+
61+
return player
62+
}
63+
}
64+
65+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package dev.slne.surf.surfapi.core.api.command.args
2+
3+
import dev.jorel.commandapi.executors.CommandArguments
4+
import kotlinx.coroutines.Deferred
5+
6+
/**
7+
* Awaits the value of a command argument that is represented as a [Deferred].
8+
*
9+
* If no argument with the given [nodeName] exists, this function returns `null`.
10+
* This is the nullable / optional variant of [awaiting].
11+
*
12+
* @param nodeName The name of the argument node.
13+
* @return The resolved argument value, or `null` if the argument is not present.
14+
*/
15+
suspend inline fun <T> CommandArguments.awaitingOrNull(nodeName: String): T? {
16+
val deferred = getUnchecked<Deferred<T>>(nodeName) ?: return null
17+
return deferred.await()
18+
}
19+
20+
/**
21+
* Awaits the value of a required command argument that is represented as a [Deferred].
22+
*
23+
* If no argument with the given [nodeName] exists, an exception is thrown.
24+
* This is the non-nullable / required variant of [awaitingOrNull].
25+
*
26+
* @param nodeName The name of the argument node.
27+
* @return The resolved argument value.
28+
* @throws IllegalStateException If the argument is missing.
29+
*/
30+
suspend inline fun <T> CommandArguments.awaiting(nodeName: String): T {
31+
return awaitingOrNull(nodeName) ?: error("Argument $nodeName is missing")
32+
}

0 commit comments

Comments
 (0)