Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(commands): Brigadier command DSL #73

Merged
merged 9 commits into from
Jun 6, 2024
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
build
target
out/
.kotlin
kotlin-js-store

.classpath
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,4 @@ package com.mineinabyss.idofront
import org.bukkit.plugin.java.JavaPlugin

class IdofrontPlugin : JavaPlugin() {
}
}
3 changes: 3 additions & 0 deletions idofront-commands/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,7 @@ plugins {

dependencies {
implementation(project(":idofront-logging"))
implementation(project(":idofront-text-components"))
implementation(libs.minecraft.mccoroutine)
implementation(libs.kotlinx.coroutines)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package com.mineinabyss.idofront.commands.brigadier

@DslMarker
annotation class Annotations
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
@file:Suppress("UnstableApiUsage")

package com.mineinabyss.idofront.commands.brigadier

import io.papermc.paper.command.brigadier.Commands
import io.papermc.paper.plugin.lifecycle.event.types.LifecycleEvents
import org.bukkit.plugin.Plugin

/**
* Idofront brigader DSL entrypoint.
*/
inline fun Commands.commands(plugin: Plugin, init: RootIdoCommands.() -> Unit) {
RootIdoCommands(this, plugin).apply(init).buildEach()
}

/**
* Idofront brigader DSL entrypoint.
*
* Must be registered in the plugin's onEnable or onLoad as it hooks into Paper's plugin lifecycle.
*/
inline fun Plugin.commands(crossinline init: RootIdoCommands.() -> Unit) {
lifecycleManager.registerEventHandler(LifecycleEvents.COMMANDS) { event ->
event.registrar().commands(this, init)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.mineinabyss.idofront.commands.brigadier

import kotlin.reflect.KProperty

class IdoArgument<T>(
val name: String,
) {
operator fun getValue(thisRef: Any?, property: KProperty<*>): IdoArgument<T> {
return this
}
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.mineinabyss.idofront.commands.brigadier

import com.mojang.brigadier.arguments.ArgumentType

data class IdoArgumentBuilder<T>(
val type: ArgumentType<out T>,
val suggestions: (suspend IdoSuggestionsContext.() -> Unit)? = null,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
package com.mineinabyss.idofront.commands.brigadier

import com.github.shynixn.mccoroutine.bukkit.asyncDispatcher
import com.mineinabyss.idofront.commands.execution.CommandExecutionFailedException
import com.mineinabyss.idofront.textcomponents.miniMsg
import com.mojang.brigadier.arguments.ArgumentType
import com.mojang.brigadier.builder.ArgumentBuilder
import com.mojang.brigadier.builder.LiteralArgumentBuilder
import com.mojang.brigadier.suggestion.SuggestionProvider
import com.mojang.brigadier.tree.LiteralCommandNode
import io.papermc.paper.command.brigadier.CommandSourceStack
import io.papermc.paper.command.brigadier.Commands
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.async
import kotlinx.coroutines.future.asCompletableFuture
import org.bukkit.entity.Player
import org.bukkit.plugin.Plugin
import kotlin.reflect.KProperty

@Suppress("UnstableApiUsage")
@Annotations
open class IdoCommand(
internal val initial: LiteralArgumentBuilder<CommandSourceStack>,
val name: String,
val plugin: Plugin,
) {
private val renderSteps = mutableListOf<RenderStep>()

fun <T> ArgumentType<T>.suggests(suggestions: suspend IdoSuggestionsContext.() -> Unit): IdoArgumentBuilder<T> {
return IdoArgumentBuilder(this, suggestions)
}

fun <T> ArgumentType<T>.suggests(provider: SuggestionProvider<CommandSourceStack>): IdoArgumentBuilder<T> {
return IdoArgumentBuilder(this) { provider.getSuggestions(context, suggestions) }
}

operator fun <T> ArgumentType<T>.provideDelegate(t: T, property: KProperty<*>): IdoArgument<T> {
add(RenderStep.Builder(Commands.argument(property.name, this)))
return IdoArgument(property.name)
}

operator fun <T> IdoArgumentBuilder<T>.provideDelegate(thisRef: Any?, property: KProperty<*>): IdoArgument<T?> {
add(RenderStep.Builder(Commands.argument(property.name, type).apply {
if (this@provideDelegate.suggestions != null)
suggests { context, builder ->
CoroutineScope(plugin.asyncDispatcher).async {
this@provideDelegate.suggestions.invoke(IdoSuggestionsContext(context, builder))
builder.build()
}.asCompletableFuture()
}
}))
return IdoArgument(property.name)
}

/** Creates a subcommand using [Commands.literal]. */
inline operator fun String.invoke(init: IdoCommand.() -> Unit) {
add(RenderStep.Command(IdoCommand(Commands.literal(this), this, plugin).apply(init)))
}

/** Specifies a predicate for the command to execute further, may be calculated more than once. */
inline fun requires(crossinline init: CommandSourceStack.() -> Boolean) = edit {
requires { init(it) }
}

/** Specifies an end node for the command that runs something, only one executes block can run per command execution. */
inline fun executes(crossinline run: IdoCommandContext.() -> Unit) = edit {
executes { context ->
try {
run(IdoCommandContext(context))
} catch (e: CommandExecutionFailedException) {
e.replyWith?.let { context.source.sender.sendMessage(it) }
}
com.mojang.brigadier.Command.SINGLE_SUCCESS
}
}

/** [executes], ensuring the executor is a player. */
inline fun playerExecutes(crossinline run: IdoPlayerCommandContext.() -> Unit) {
executes {
if (executor !is Player) commandException("<red>This command can only be run by a player.".miniMsg())
run(IdoPlayerCommandContext(context))
}
}

@PublishedApi
internal fun add(step: RenderStep) {
renderSteps += step
}

/** Directly edit the command in Brigadier. */
inline fun edit(crossinline apply: IdoArgBuilder.() -> ArgumentBuilder<*, *>) {
add(RenderStep.Apply { apply() as IdoArgBuilder })
}

internal fun render(): List<RenderedCommand> {
return renderSteps.foldRight(listOf()) { step, acc ->
step.reduce(acc)
}
}

internal fun build(): LiteralCommandNode<CommandSourceStack> {
render().fold(initial as IdoArgBuilder) { acc, curr ->
curr.foldLeft(acc)
}
return initial.build()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package com.mineinabyss.idofront.commands.brigadier

import com.mineinabyss.idofront.commands.execution.CommandExecutionFailedException
import com.mineinabyss.idofront.textcomponents.miniMsg
import com.mojang.brigadier.context.CommandContext
import io.papermc.paper.command.brigadier.CommandSourceStack
import io.papermc.paper.command.brigadier.argument.resolvers.ArgumentResolver
import net.kyori.adventure.text.Component
import org.bukkit.Location
import org.bukkit.command.CommandSender
import org.bukkit.entity.Entity

@Annotations
@Suppress("UnstableApiUsage")
open class IdoCommandContext(
val context: CommandContext<CommandSourceStack>,
) {
/** Stops the command, sending a [message] formatted with MiniMessage to its [sender]. */
fun commandException(message: String): Nothing = throw CommandExecutionFailedException(message.miniMsg())

/** Stops the command, sending a [message] to its [sender]. */
fun commandException(message: Component): Nothing = throw CommandExecutionFailedException(message)

/** The sender that ran this command. */
val sender: CommandSender = context.source.sender

/** An entity representing the [sender] on the server. */
val executor: Entity? = context.source.executor

val location: Location = context.source.location

@JvmName("invoke1")
inline operator fun <reified T> IdoArgument<out ArgumentResolver<T>>.invoke(): T {
@Suppress("UNCHECKED_CAST") // getArgument logic ensures this cast always succeeds if the argument was registered
return ((this as IdoArgument<Any?>).invoke() as ArgumentResolver<T>)
.resolve(context.source)
}

@JvmName("invoke2")
inline operator fun <reified T> IdoArgument<T>.invoke(): T {
return context.getArgumentOrNull<T>(name)
?: commandException("<red>Argument $name not found".miniMsg())
}

@PublishedApi
internal inline fun <reified T> CommandContext<CommandSourceStack>.getArgumentOrNull(name: String): T? = runCatching {
context.getArgument(name, T::class.java)
}.getOrNull()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.mineinabyss.idofront.commands.brigadier

import com.mojang.brigadier.context.CommandContext
import io.papermc.paper.command.brigadier.CommandSourceStack
import org.bukkit.entity.Player

@Annotations
@Suppress("UnstableApiUsage")
class IdoPlayerCommandContext(
context: CommandContext<CommandSourceStack>,
): IdoCommandContext(context) {
val player = executor as Player
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.mineinabyss.idofront.commands.brigadier

import com.mojang.brigadier.builder.LiteralArgumentBuilder
import io.papermc.paper.command.brigadier.CommandSourceStack
import org.bukkit.plugin.Plugin

@Annotations
@Suppress("UnstableApiUsage")
class IdoRootCommand(
initial: LiteralArgumentBuilder<CommandSourceStack>,
name: String,
val description: String?,
val aliases: List<String>,
plugin: Plugin,
) : IdoCommand(initial, name, plugin)
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.mineinabyss.idofront.commands.brigadier

import com.mojang.brigadier.context.CommandContext
import com.mojang.brigadier.suggestion.SuggestionsBuilder
import io.papermc.paper.command.brigadier.CommandSourceStack

@Suppress("UnstableApiUsage")
data class IdoSuggestionsContext(
val context: CommandContext<CommandSourceStack>,
val suggestions: SuggestionsBuilder,
) {
/** Add a suggestion, filtering it as the user types. */
fun suggestFiltering(name: String) {
if (name.startsWith(suggestions.remaining, ignoreCase = true))
suggestions.suggest(name)
}

/** Add a list of suggestions, filtering them as the user types. */
fun suggest(list: List<String>) {
list.forEach { suggestFiltering(it) }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.mineinabyss.idofront.commands.brigadier

/**
* These are emitted line by line to reflect what a user specifies in the DSL.
*/
@Suppress("UnstableApiUsage")
sealed interface RenderStep {
fun reduce(rightAcc: List<RenderedCommand>): List<RenderedCommand>

class Builder(val builder: IdoArgBuilder) : RenderStep {
override fun reduce(rightAcc: List<RenderedCommand>): List<RenderedCommand> {
return listOf(RenderedCommand.ThenFold(builder, rightAcc))
}
}

class Command(val command: IdoCommand) : RenderStep {
override fun reduce(rightAcc: List<RenderedCommand>): List<RenderedCommand> {
return listOf(RenderedCommand.ThenFold(command.initial, command.render())) + rightAcc
}
}

class Apply(val apply: IdoArgBuilder.() -> Unit) : RenderStep {
override fun reduce(rightAcc: List<RenderedCommand>): List<RenderedCommand> {
return listOf(RenderedCommand.Apply(apply)) + rightAcc
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.mineinabyss.idofront.commands.brigadier

/**
* [RenderStep]s get reduced into a list of commands that more directly represent Brigadier's builder structure.
*
* This lets us write more complex nodes more easily.
*/
sealed interface RenderedCommand {
fun foldLeft(acc: IdoArgBuilder): IdoArgBuilder

data class Apply(val apply: IdoArgBuilder.() -> Unit) : RenderedCommand {
override fun foldLeft(acc: IdoArgBuilder) = acc.apply(apply)
}

data class ThenFold(val initial: IdoArgBuilder, val list: List<RenderedCommand>) : RenderedCommand {
override fun foldLeft(acc: IdoArgBuilder) = acc.apply {
then(list.fold(initial) { acc, next -> next.foldLeft(acc) })
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package com.mineinabyss.idofront.commands.brigadier

import io.papermc.paper.command.brigadier.Commands
import org.bukkit.plugin.Plugin

@Suppress("UnstableApiUsage")
class RootIdoCommands(
val commands: Commands,
val plugin: Plugin,
) {
@PublishedApi
internal val rootCommands = mutableListOf<IdoRootCommand>()

/** Creates a new subcommand via a [Commands.literal] argument. */
inline operator fun String.invoke(aliases: List<String> = emptyList(), description: String? = null, init: IdoRootCommand.() -> Unit) {
rootCommands += IdoRootCommand(
Commands.literal(this),
this,
description,
aliases,
plugin,
).apply(init)
}

/** Creates a new subcommand with aliases via a [Commands.literal] argument. */
inline operator fun List<String>.invoke(description: String? = null, init: IdoRootCommand.() -> Unit) =
firstOrNull()?.invoke(aliases = drop(1), description = description, init = init)

/** Builder for commands with aliases. */
operator fun String.div(other: String) = listOf(this, other)

/** Builder for commands with aliases. */
operator fun List<String>.div(other: String) = listOf(this) + other

/** Builds and registers each root level command defined in the DSL. */
@PublishedApi
internal fun buildEach() {
rootCommands.forEach { command ->
commands.register(
command.build(),
command.description,
command.aliases
)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.mineinabyss.idofront.commands.brigadier

import com.mojang.brigadier.builder.ArgumentBuilder
import com.mojang.brigadier.tree.CommandNode
import io.papermc.paper.command.brigadier.CommandSourceStack

@Suppress("UnstableApiUsage")
internal typealias IdoArgBuilder = ArgumentBuilder<CommandSourceStack, *>
internal typealias IdoCommandNode = CommandNode<CommandSourceStack>

fun IdoArgBuilder.thenCast(other: IdoArgBuilder): IdoArgBuilder = then(other) as IdoArgBuilder

fun IdoArgBuilder.thenCast(other: IdoCommandNode): IdoArgBuilder = then(other) as IdoArgBuilder
Loading
Loading