diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9c3aa58..a9ae5e3 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -29,9 +29,8 @@ jobs: run: ./gradlew build - name: Upload JAR artifact - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v4 with: name: DaisyCommand - path: Jar/*.jar + path: build/libs/*.jar if-no-files-found: warn - diff --git a/Jar/DaisyCommand-1.0.0.jar b/Jar/DaisyCommand-1.0.0.jar index ad18776..59e2aa8 100644 Binary files a/Jar/DaisyCommand-1.0.0.jar and b/Jar/DaisyCommand-1.0.0.jar differ diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 0000000..b5e440a --- /dev/null +++ b/MIGRATION.md @@ -0,0 +1,208 @@ +# Migrating From DaisyCommands 2.x To 3.0 + +DaisyCommands 3.0 is a cleanup release focused on one thing: a Kotlin-only, Paper-first command API with one obvious DSL. If you are upgrading from 2.x, this guide covers the main breaking changes and the new preferred patterns. + +## What Changed In 3.0 + +- DaisyCommands is now explicitly Kotlin-only in public API design and documentation. +- Legacy compatibility APIs were removed from the main artifact. +- String-key argument access was removed from execution contexts. +- Flags and options are now first-class alongside positional arguments. +- Message customization moved to `registerCommands { config { ... } }`. +- The repository branding is now `DaisyCatTs/DaisyCommand`. +- Package namespace and current published coordinates stay stable for now. + +## Upgrade Checklist + +- Replace any legacy command builders with `command(name) { ... }`. +- Replace string-key getters with typed `ArgumentRef` values. +- Move registration to `registerCommands(...)` or `registerCommands { ... }`. +- Convert optional values to `optional()` or `default(...)`. +- Move message customization into registration config. +- Adopt flags and options where they remove positional boilerplate. +- Review plugin assumptions for Paper `1.21.11` and Java `21`. + +## API Before And After + +### Registration + +Before: + +```kotlin +registerCommands( + command("hello") { + execute { + reply("Hello") + } + }, +) +``` + +After: + +```kotlin +registerCommands { + config { + messages { + prefix = "[Example] " + } + } + + command("hello") { + execute { + reply("Hello") + } + } +} +``` + +### Typed Positional Access + +Before: + +```kotlin +command("island") { + sub("invite") { + val target = player("target") + + executePlayer { + reply("Invited ${target().name}") + } + } +} +``` + +After: + +```kotlin +command("island") { + sub("invite") { + val target = player("target") + val silent = flag("silent", "s") + + executePlayer { + islandService.invite(player, target(), silent()) + } + } +} +``` + +### Optional And Defaulted Values + +Before: + +```kotlin +command("mail") { + val message = text("message").optional() + + execute { + reply(message() ?: "Empty") + } +} +``` + +After: + +```kotlin +command("mail") { + val message = text("message").optional() + val mode = choice("mode", "normal", "priority").default("normal") + + executePlayer { + reply("${mode()}: ${message() ?: "Empty"}") + } +} +``` + +### Flags And Options + +Before: + +```kotlin +command("ban") { + val target = player("target") + executePlayer { } +} +``` + +After: + +```kotlin +command("ban") { + val target = player("target") + val reason = stringOption("reason", "r").default("No reason") + val silent = flag("silent", "s") + + executePlayer { + moderationService.ban(player, target(), reason(), silent()) + } +} +``` + +## Registration Changes + +The two supported registration paths are: + +```kotlin +registerCommands( + command("example") { + execute { } + }, +) +``` + +```kotlin +registerCommands { + config { + messages { + prefix = "[Example] " + } + } + + command("example") { + execute { } + } +} +``` + +Use the builder form when you want framework-wide message or theme customization. + +## Arguments And Typed Access + +3.0 expects you to define refs during DSL construction and resolve them directly inside handlers: + +```kotlin +command("warp") { + val target = player("target") + val expires = durationOption("expires", "e").optional() + + executePlayer { + reply("Warping ${target().name} for ${expires()}.") + } +} +``` + +The main pattern is: + +- required ref -> non-null typed value +- `optional()` -> nullable typed value +- `default(value)` -> non-null fallback value + +## Removed Main-Artifact APIs + +These are no longer part of the main 3.0 artifact: + +- deprecated compatibility builders from older releases +- string-key getters like `getString("name")` +- broad legacy execution helpers that duplicated the typed-ref flow + +The 3.0 API is intentionally narrower so plugin authors have one clear way to write commands. + +## Breaking Changes Summary + +- Kotlin-only public API direction +- Paper `1.21.11` target +- legacy compatibility APIs removed from the main artifact +- string-key argument access removed +- flags and options added as first-class syntax +- docs and examples now assume the 3.0 DSL only diff --git a/README.md b/README.md index 0293cb9..1de206f 100644 --- a/README.md +++ b/README.md @@ -1,651 +1,247 @@

- DaisyCommand + Kotlin Only + Paper 1.21.11 + Version 3.0.0

-

🌸 DaisyCommand

+

DaisyCommands

- A modern, high-performance Kotlin command framework for Paper/Spigot plugins + Kotlin-first command framework for modern Paper plugins.

- JitPack - License: MIT - Kotlin - Paper - Java 21+ + Typed arguments, clean DSL, flags and options, fast routing, and Paper-native registration with one obvious API.

-

- Features • - Installation • - Quick Start • - DSL Reference • - Arguments • - Context • - Advanced -

+## Why DaisyCommands ---- +- Built for Kotlin plugin authors first, not Java builders translated into Kotlin. +- Uses typed `ArgumentRef` values instead of string-key argument lookups. +- Keeps one clear DSL instead of multiple overlapping command styles. +- Supports real plugin command trees with subcommands, requirements, options, cooldowns, and completions. +- Generates help and usage from the compiled command model. +- Stays focused on commands instead of turning into a giant framework. -## ✨ Features +## Features -| Feature | Description | -|---------|-------------| -| 🎯 **Beautiful Kotlin DSL** | Clean, type-safe, expressive syntax for defining commands | -| ☕ **Full Java Support** | Fluent API that works seamlessly with Java plugins | -| 🎨 **MiniMessage Native** | Built-in support for gradients, hex colors, and modern formatting | -| 🔒 **Type-Safe Arguments** | 17+ built-in parsers with automatic validation | -| 📁 **Nested Subcommands** | Unlimited subcommand depth with independent permissions | -| ⏱️ **Cooldown System** | Thread-safe cooldowns with bypass permissions | -| 📝 **Smart Tab Completion** | Automatic completions + custom providers | -| ⚡ **Zero Configuration** | No plugin.yml command entries needed | -| 🛡️ **Security First** | Input validation, length limits, and sanitization | -| 🚀 **High Performance** | Zero-reflection execution after initial setup | +| Feature | What it gives you | +|---|---| +| Nested subcommands | Clean command trees with `sub(...)` | +| Typed refs | `ArgumentRef` values resolved directly inside handlers | +| Positional args plus flags/options | `player("target")`, `flag("silent")`, `durationOption("expires")` | +| Optional and defaulted values | `optional()` and `default(...)` for cleaner handlers | +| Built-in parsers | Strings, text, numbers, booleans, players, worlds, materials, UUIDs, durations, choices, enums, and custom parsers | +| Sender constraints | `playerOnly()`, `consoleOnly()`, `executePlayer {}`, `executeConsole {}` | +| Custom requirements | `requires { ... }` checks alongside permissions | +| Path-scoped cooldowns | Success-only cooldowns with bypass permissions | +| Generated help and usage | Container help, usage output, and argument errors out of the box | +| Message theming | Lightweight `config { messages { ... } theme { ... } }` customization | ---- +## Installation -## 📦 Installation +Requirements: Java 21, modern Paper 1.21.11, Kotlin-first API usage. -### Gradle (Kotlin DSL) +Repository identity is now `DaisyCatTs/DaisyCommand`. Published coordinates stay stable for now. + +### Gradle Kotlin DSL ```kotlin repositories { mavenCentral() + maven("https://repo.papermc.io/repository/maven-public/") maven("https://jitpack.io") } dependencies { - implementation("com.github.fu3i0n:DaisyCommand:1.0.0") + implementation("com.github.fu3i0n:DaisyCommand:3.0.0") } ``` -### Gradle (Groovy) +### Gradle Groovy ```groovy repositories { mavenCentral() + maven { url 'https://repo.papermc.io/repository/maven-public/' } maven { url 'https://jitpack.io' } } dependencies { - implementation 'com.github.fu3i0n:DaisyCommand:1.0.0' + implementation 'com.github.fu3i0n:DaisyCommand:3.0.0' } ``` -### Maven - -```xml - - - jitpack.io - https://jitpack.io - - - - - com.github.fu3i0n - DaisyCommand - 1.0.0 - -``` - -> 💡 **Tip:** Replace `1.0.0` with `main-SNAPSHOT` for the latest development build. - ---- - -## 🚀 Quick Start - -### Kotlin +## Quick Start ```kotlin -import cat.daisy.command.core.DaisyCommands -import cat.daisy.command.dsl.daisyCommand +import cat.daisy.command.core.registerCommands +import cat.daisy.command.dsl.command import org.bukkit.plugin.java.JavaPlugin -class MyPlugin : JavaPlugin() { - +class ExamplePlugin : JavaPlugin() { override fun onEnable() { - // Initialize DaisyCommand - DaisyCommands.initialize(this) - - // Create your first command! - daisyCommand("hello") { - description = "Say hello to a player" - permission = "myplugin.hello" - - playerArgument("target", optional = true) - - playerExecutor { - val target = getPlayer("target") ?: player - success("Hello, ${target.name}!") - } - } - } - - override fun onDisable() { - DaisyCommands.shutdown() - } -} -``` - -### Java - -```java -import cat.daisy.command.DaisyCommandAPI; -import cat.daisy.command.core.DaisyCommands; -import org.bukkit.entity.Player; -import org.bukkit.plugin.java.JavaPlugin; - -public class MyPlugin extends JavaPlugin { - - @Override - public void onEnable() { - // Initialize DaisyCommand - DaisyCommands.INSTANCE.initialize(this); - - // Create your first command! - DaisyCommandAPI.create("hello", builder -> { - builder.setDescription("Say hello to a player"); - builder.setPermission("myplugin.hello"); - builder.playerArgument("target", true); // optional = true - - builder.onExecute(ctx -> { - Player target = ctx.getPlayer("target"); - if (target == null) target = ctx.getPlayer(); - if (target != null) { - ctx.success("Hello, " + target.getName() + "!"); + registerCommands( + command("island") { + sub("create") { + playerOnly() + + executePlayer { + reply("Island created for ${player.name}.") + } } - }); - }); - } - - @Override - public void onDisable() { - DaisyCommands.INSTANCE.shutdown(); + }, + ) } } ``` ---- - -## 📖 DSL Reference - -### Basic Command +## DSL Overview ```kotlin -daisyCommand("example") { - description = "Example command" - permission = "myplugin.example" - withAliases("ex", "e") - - onExecute { - success("Hello from example command!") - } -} -``` +command("island") { + description("Island management") + aliases("is") -### Player-Only Command + sub("invite") { + permission("island.invite") + val target = player("target") + val silent = flag("silent", "s") -```kotlin -daisyCommand("fly") { - description = "Toggle flight" - permission = "myplugin.fly" - - playerExecutor { - player.allowFlight = !player.allowFlight - val status = if (player.allowFlight) "enabled" else "disabled" - successWithSound("Flight $status!") - } -} -``` - -### Subcommands - -```kotlin -daisyCommand("team") { - description = "Team management" - - subcommand("create") { - description = "Create a team" - permission = "team.create" - stringArgument("name") - - onExecute { - val name = getString("name") ?: return@onExecute error("Name required!") - success("Team '$name' created!") + executePlayer { + islandService.invite(player, target(), silent()) } } - - subcommand("invite") { - description = "Invite a player" - playerArgument("player") - - playerExecutor { - val target = getPlayer("player") ?: return@playerExecutor error("Player not found!") - success("Invited ${target.name}!") - } - } - - // Nested subcommands work too! - subcommand("settings") { - subcommand("name") { - stringArgument("newName") - onExecute { success("Renamed to '${getString("newName")}'!") } - } - - subcommand("privacy") { - choiceArgument("mode", "public", "private", "invite") - onExecute { success("Privacy set to ${getString("mode")}!") } - } - } -} -``` -### Cooldowns + sub("visit") { + val island = string("name") + val expires = durationOption("expires", "e").optional() -```kotlin -daisyCommand("heal") { - description = "Heal yourself" - cooldown = 60 // 60 seconds - cooldownMessage = "Wait {remaining} seconds!" - cooldownBypassPermission = "myplugin.heal.bypass" - - playerExecutor { - player.health = player.maxHealth - successWithSound("Healed!") + execute { + reply("Visiting ${island()} for ${expires() ?: java.time.Duration.ofMinutes(5)}.") + } } } ``` ---- - -## 🔧 Argument Types - -DaisyCommand provides **17+ built-in argument types** with automatic validation and tab completion: - -### Primitives - -| Method | Type | Description | -|--------|------|-------------| -| `stringArgument` | `String` | Single word (max 256 chars) | -| `greedyStringArgument` | `String` | All remaining text (max 1024 chars) | -| `intArgument` | `Int` | Integer with optional min/max | -| `longArgument` | `Long` | Long integer with optional min/max | -| `doubleArgument` | `Double` | Decimal with optional min/max | -| `floatArgument` | `Float` | Float with optional min/max | -| `booleanArgument` | `Boolean` | `true/false`, `yes/no`, `on/off`, `1/0` | - -### Minecraft Types - -| Method | Type | Description | -|--------|------|-------------| -| `playerArgument` | `Player` | Online player with tab completion | -| `offlinePlayerArgument` | `OfflinePlayer` | Any player who has joined | -| `worldArgument` | `World` | Loaded world with tab completion | -| `materialArgument` | `Material` | Minecraft material | -| `gameModeArgument` | `GameMode` | survival, creative, spectator, adventure | -| `entityTypeArgument` | `EntityType` | Entity type | - -### Special Types - -| Method | Type | Description | -|--------|------|-------------| -| `uuidArgument` | `UUID` | Valid UUID | -| `durationArgument` | `Duration` | Time format: `1d2h30m45s` | -| `choiceArgument` | `String` | Fixed set of choices | -| `enumArgument` | `E` | Any enum type | -| `customArgument` | `T` | Custom parser | - -### Examples +## Typed Arguments ```kotlin -// Range validation -daisyCommand("setlevel") { - intArgument("level", min = 1, max = 100) - - playerExecutor { - val level = getInt("level")!! - success("Level set to $level!") - } -} - -// Enum argument -daisyCommand("gamemode") { - enumArgument("mode") - playerArgument("target", optional = true) - - onExecute { - val mode = getArg("mode")!! - val target = getPlayer("target") ?: player!! - target.gameMode = mode - success("Set ${target.name}'s gamemode to ${mode.name}!") - } -} - -// Duration parsing (1d2h30m = 1 day, 2 hours, 30 minutes) -daisyCommand("tempban") { - playerArgument("player") - durationArgument("duration") - greedyStringArgument("reason", optional = true) - - onExecute { - val target = getPlayer("player")!! - val duration = getArg("duration")!! - val reason = getString("reason") ?: "No reason" - success("Banned ${target.name} for ${duration.toMinutes()} minutes: $reason") - } -} +command("mail") { + val target = player("target") + val message = text("message").optional() + val mode = choice("mode", "normal", "priority").default("normal") -// Custom choices -daisyCommand("difficulty") { - choiceArgument("level", "peaceful", "easy", "normal", "hard") - - onExecute { - val level = getString("level")!! - success("Difficulty set to $level!") + executePlayer { + reply("Mail to ${target().name}: ${message() ?: "No message"} (${mode()})") } } ``` ---- - -## 💬 Context API - -### Argument Access - -```kotlin -// Named arguments (from DSL definitions) -getString("name") // String? -getInt("amount") // Int? -getLong("timestamp") // Long? -getDouble("multiplier") // Double? -getFloat("speed") // Float? -getBoolean("enabled") // Boolean? -getPlayer("target") // Player? -getArg("key") // T? - -// Positional arguments (raw access) -arg(0) // String? - first argument -argInt(0) // Int? -argDouble(0) // Double? -argPlayer(0) // Player? -argOr(0, "default") // String with default -joinArgs(1) // Join args from index 1 -argCount // Number of arguments -``` - -### Messaging (MiniMessage) - -```kotlin -// Basic messaging -send("Hello World!") -reply("This is a reply") - -// Prefixed messages -success("Operation completed!") // ✔ green prefix -error("Something went wrong!") // ✖ red prefix -warn("Be careful!") // ⚠ yellow prefix -info("Did you know?") // ✦ blue prefix - -// Broadcast to all players -broadcast("Server announcement!") -``` +`ArgumentRef` values are declared while building the command and resolved by calling the ref like a function inside the handler. Required refs are non-null. Optional refs return nullable values. Defaulted refs resolve to the provided value when omitted. -### Player-Only Features (PlayerContext) +## Flags And Options ```kotlin -playerExecutor { - // Action bar - actionBar("+50 XP") - - // Titles - title( - title = "Level Up!", - subtitle = "You are now level 10", - fadeIn = Duration.ofMillis(500), - stay = Duration.ofSeconds(3), - fadeOut = Duration.ofMillis(500) - ) - - // Sounds - sound(Sound.ENTITY_PLAYER_LEVELUP, volume = 0.5f, pitch = 1.5f) - - // Combined message + sound - successWithSound("Achievement unlocked!") - errorWithSound("Not enough resources!") - infoWithSound("New quest available!") -} -``` +command("ban") { + val target = player("target") + val silent = flag("silent", "s") + val reason = stringOption("reason", "r").default("No reason") + val expires = durationOption("expires", "e").optional() -### Flow Control - -```kotlin -onExecute { - // Require player - requirePlayer { - player.health = player.maxHealth - } - - // Require permission - requirePermission("admin.special") { - success("Admin action performed!") - } - - // Require argument count - requireArgs(2, "Usage: /cmd ") { - // Only runs if at least 2 args provided - } - - // Parse with validation - withPlayer(0) { target -> - success("Found player: ${target.name}") - } - - withInt(1) { amount -> - success("Amount: $amount") + executePlayer { + moderationService.ban( + actor = player, + target = target(), + reason = reason(), + silent = silent(), + expires = expires(), + ) } } ``` ---- - -## 📝 Tab Completion - -### Automatic Completion +Supported syntax: -Arguments automatically provide tab completion: -- `playerArgument` → Online player names -- `worldArgument` → Loaded world names -- `materialArgument` → Material names -- `gameModeArgument` → Gamemode names -- `booleanArgument` → "true", "false" -- `choiceArgument` → Your defined choices -- `enumArgument` → Enum values +- `--reason griefing` +- `-r griefing` +- `--silent` +- options before or after positional arguments +- `--` to stop option parsing -### Custom Completion +## Execution Contexts ```kotlin -daisyCommand("warp") { - stringArgument("location") - - tabComplete { - when (argIndex) { - 0 -> filter("spawn", "hub", "arena", "shop", "mine") - else -> none() +command("demo") { + sub("info") { + execute { + reply("Sender: ${sender.name}") } } -} -``` -### TabContext Helpers + sub("heal") { + executePlayer { + reply("Healing ${player.name}.") + } + } -```kotlin -tabComplete { - players() // Online player names (filtered) - worlds() // World names (filtered) - filter("a", "b") // Filter options by current input - currentArg // Current argument being typed - argIndex // Index of current argument (0-based) - none() // Empty list + sub("reload") { + executeConsole { + reply("Reload requested by console.") + } + } } ``` ---- +- `execute {}` works for any sender. +- `executePlayer {}` exposes `player`. +- `executeConsole {}` exposes `console`. +- Sender constraints are enforced before the handler runs. -## 🎨 DaisyText +## Permissions, Help, And Cooldowns -MiniMessage utilities available throughout your code: +- Parent permissions and requirements are inherited by children. +- Container nodes show generated help output automatically. +- Invalid input renders an error plus usage. +- Cooldowns apply only after successful execution. +- Message styling can be customized through registration config. ```kotlin -import cat.daisy.command.text.DaisyText.mm -import cat.daisy.command.text.DaisyText.Colors - -// Parse MiniMessage to Component -val component = "Hello!".mm() - -// Gradients -val rainbow = "Rainbow Text".rainbow() -val custom = "Custom".gradient("#FF0000", "#00FF00") - -// Placeholders -val msg = "Hello, {player}!".replacePlaceholders("player" to player.name) - -// Predefined colors -Colors.PRIMARY // #3498db -Colors.SUCCESS // #2ecc71 -Colors.ERROR // #e74c3c -Colors.WARNING // #f1c40f - -// Legacy color conversion -val converted = "&aGreen &cRed".convertLegacyColors() +import java.time.Duration -// Strip all formatting -val plain = "Hello".stripColors() -``` - ---- - -## 🔄 Lifecycle & Dynamic Registration - -```kotlin -class MyPlugin : JavaPlugin() { - - override fun onEnable() { - // Initialize FIRST - DaisyCommands.initialize(this) - - // Register commands - registerCommands() - } - - override fun onDisable() { - // Cleanup - unregisters all commands and clears cooldowns - DaisyCommands.shutdown() +registerCommands { + config { + messages { + prefix = "[Islands] " + } } -} -``` - -### Dynamic Registration - -```kotlin -// Build without registering -val cmd = buildCommand("dynamic") { - onExecute { success("Dynamic!") } -} - -// Register later -DaisyCommands.register(cmd) - -// Unregister -DaisyCommands.unregister("dynamic") - -// Check status -DaisyCommands.isRegistered("hello") - -// Get all commands -DaisyCommands.getAll() -``` - ---- - -## 🛡️ Security - -DaisyCommand includes comprehensive security measures: -### Input Validation -- **String limits**: Single args max 256 chars, greedy max 1024 chars -- **Type validation**: All parsers validate before processing -- **Range checking**: Numeric arguments support min/max + command("island") { + permission("island.use") + requires("You must own an island.") { player.hasPermission("island.owner") } -### Permission System -- Commands check permissions before execution -- Subcommands have independent permissions -- Tab completion respects permissions + sub("home") { + cooldown( + Duration.ofSeconds(30), + bypassPermission = "island.home.bypass", + ) -### Thread Safety -- `ConcurrentHashMap` for command/cooldown storage -- Safe for async access patterns - -### Best Practices - -```kotlin -daisyCommand("admin") { - permission = "myplugin.admin" // Always set for sensitive commands - - subcommand("execute") { - greedyStringArgument("command") - - onExecute { - val cmd = getString("command") ?: return@onExecute - // Add additional validation as needed - if (cmd.contains("dangerous")) { - error("Blocked!") - return@onExecute + executePlayer { + reply("Teleported home.") } } } } ``` ---- - -## 📋 Requirements - -| Requirement | Version | -|-------------|---------| -| Java | 21+ | -| Kotlin | 2.1.0+ | -| Paper | 1.21+ (or compatible fork) | - ---- - -## 📄 License - -This project is licensed under the **MIT License** - see the [LICENSE](LICENSE) file for details. +## Why Kotlin-Only ---- +DaisyCommands 3.0 is intentionally optimized for Kotlin plugin authors. The public API is designed around Kotlin nullability, DSL ergonomics, and typed references. There are no legacy Java-style builders or string-key-first command handlers in the main artifact. -## 🤝 Contributing +## Migration Note -Contributions are welcome! Please feel free to submit a Pull Request. +3.0 is intentionally breaking. Legacy compatibility APIs were removed from the main artifact, string-key argument access is gone, and the docs now assume the Kotlin-first DSL only. See [MIGRATION.md](MIGRATION.md) for the 2.x to 3.0 upgrade notes. -1. Fork the repository -2. Create your feature branch (`git checkout -b feature/amazing`) -3. Commit your changes (`git commit -m 'Add amazing feature'`) -4. Push to the branch (`git push origin feature/amazing`) -5. Open a Pull Request - ---- - -## 📞 Support - -- 🐛 **Issues**: [GitHub Issues](https://github.com/fu3i0n/DaisyCommand/issues) -- 💬 **Discussions**: [GitHub Discussions](https://github.com/fu3i0n/DaisyCommand/discussions) - ---- - -

- Made with 💜 by fu3i0n -

+## License +MIT. See [LICENSE](LICENSE). diff --git a/build.gradle.kts b/build.gradle.kts index fa84339..64d275f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -3,13 +3,13 @@ val ktlint by configurations.creating plugins { - kotlin("jvm") version "2.3.0" + kotlin("jvm") version "2.3.20" `maven-publish` `java-library` } group = "com.github.fu3i0n" -version = "1.0.0" +version = "3.0.0" repositories { mavenCentral() @@ -18,8 +18,8 @@ repositories { val versions = mapOf( - "paperApi" to "1.21.10-R0.1-SNAPSHOT", - "kotlin" to "2.3.0", + "paperApi" to "1.21.11-R0.1-SNAPSHOT", + "kotlin" to "2.3.20", "ktlint" to "1.8.0", ) @@ -27,6 +27,11 @@ dependencies { compileOnly("io.papermc.paper:paper-api:${versions["paperApi"]}") compileOnly("org.jetbrains.kotlin:kotlin-stdlib-jdk8:${versions["kotlin"]}") + testImplementation(kotlin("test")) + testImplementation("org.junit.jupiter:junit-jupiter:5.13.4") + testImplementation("org.mockito:mockito-core:5.20.0") + testImplementation("io.papermc.paper:paper-api:${versions["paperApi"]}") + ktlint("com.pinterest.ktlint:ktlint-cli:${versions["ktlint"]}") { attributes { attribute(Bundling.BUNDLING_ATTRIBUTE, objects.named(Bundling.EXTERNAL)) @@ -62,7 +67,7 @@ val ktlintCheck by tasks.registering(JavaExec::class) { tasks { check { - dependsOn("ktlintFormat") + dependsOn(ktlintCheck) } register("ktlintFormat") { @@ -74,17 +79,8 @@ tasks { args("-F", "**/src/**/*.kt", "**.kts", "!**/build/**") } - val jarDir = layout.projectDirectory.dir("Jar") - val projectVersion = version.toString() - - register("copyToJar") { - from(jar) - into(jarDir) - rename { "DaisyCommand-$projectVersion.jar" } - } - - build { - finalizedBy("copyToJar") + test { + useJUnitPlatform() } } diff --git a/gradle.properties b/gradle.properties index 3209f91..ea123fc 100644 --- a/gradle.properties +++ b/gradle.properties @@ -21,8 +21,6 @@ org.gradle.daemon=true org.gradle.vfs.watch=true # Enable build scans org.gradle.enterprise.buildScan.uploadInBackground=true -# Improve Kotlin compiler performance -kotlin.compiler.preciseCompilationResultsBackup=true # Optimize Kotlin compilation kotlin.build.report.output=build_scan # Set JDK toolchain caching diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 19a6bde..c61a118 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.0-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/src/main/kotlin/cat/daisy/command/DaisyCommand.kt b/src/main/kotlin/cat/daisy/command/DaisyCommand.kt deleted file mode 100644 index e411682..0000000 --- a/src/main/kotlin/cat/daisy/command/DaisyCommand.kt +++ /dev/null @@ -1,123 +0,0 @@ -@file:JvmName("DaisyCommand") -@file:Suppress("unused") - -package cat.daisy.command - -import cat.daisy.command.context.CommandContext -import cat.daisy.command.context.PlayerContext -import cat.daisy.command.context.TabContext -import cat.daisy.command.core.DaisyCommands -import cat.daisy.command.core.SubCommand -import cat.daisy.command.dsl.DaisyCommandBuilder - -/** - * # DaisyCommand - Modern Kotlin Command Framework for Paper/Spigot - * - * A high-performance, type-safe command framework featuring: - * - **Beautiful Kotlin DSL** - Clean, expressive syntax - * - **Full Java Support** - Works seamlessly with Java plugins - * - **MiniMessage Integration** - Modern text formatting with gradients and hex colors - * - **Type-Safe Arguments** - Built-in parsers with validation - * - **Nested Subcommands** - Infinite nesting with independent permissions - * - **Cooldown System** - Thread-safe with bypass permissions - * - **Smart Tab Completion** - Automatic and customizable - * - **Zero Configuration** - No plugin.yml entries needed - * - **Security First** - Input validation and sanitization - * - * ## Quick Start (Kotlin) - * ```kotlin - * class MyPlugin : JavaPlugin() { - * override fun onEnable() { - * DaisyCommands.initialize(this) - * - * daisyCommand("greet") { - * description = "Greet a player" - * permission = "myplugin.greet" - * playerArgument("target") - * - * playerExecutor { - * val target = getPlayer("target") ?: player - * success("Hello, ${target.name}!") - * } - * } - * } - * - * override fun onDisable() { - * DaisyCommands.shutdown() - * } - * } - * ``` - * - * ## Quick Start (Java) - * ```java - * public class MyPlugin extends JavaPlugin { - * @Override - * public void onEnable() { - * DaisyCommands.INSTANCE.initialize(this); - * - * DaisyCommandAPI.create("greet", builder -> { - * builder.setDescription("Greet a player"); - * builder.setPermission("myplugin.greet"); - * builder.playerArgument("target", false); - * builder.onExecute(ctx -> { - * ctx.success("Hello!"); - * }); - * }); - * } - * - * @Override - * public void onDisable() { - * DaisyCommands.INSTANCE.shutdown(); - * } - * } - * ``` - * - * @author Daisy - * @version 1.0.0 - * @see DaisyCommands - */ -object DaisyCommandAPI { - /** Current library version */ - const val VERSION = "1.0.0" - - /** - * Create and register a command (Java-friendly). - * @param name Command name - * @param configure Configuration block - * @return The created command - */ - @JvmStatic - fun create( - name: String, - configure: java.util.function.Consumer, - ): cat.daisy.command.core.DaisyCommand { - val builder = DaisyCommandBuilder(name) - configure.accept(builder) - val command = builder.build() - DaisyCommands.register(command) - return command - } - - /** - * Build a command without registering it (Java-friendly). - * @param name Command name - * @param configure Configuration block - * @return The created command - */ - @JvmStatic - fun build( - name: String, - configure: java.util.function.Consumer, - ): cat.daisy.command.core.DaisyCommand { - val builder = DaisyCommandBuilder(name) - configure.accept(builder) - return builder.build() - } -} - -// Type aliases for convenience -typealias DaisyCmd = cat.daisy.command.core.DaisyCommand -typealias SubCmd = SubCommand -typealias CmdContext = CommandContext -typealias PlayerCtx = PlayerContext -typealias TabCtx = TabContext diff --git a/src/main/kotlin/cat/daisy/command/arguments/Arguments.kt b/src/main/kotlin/cat/daisy/command/arguments/Arguments.kt index 0a2659f..a08c59c 100644 --- a/src/main/kotlin/cat/daisy/command/arguments/Arguments.kt +++ b/src/main/kotlin/cat/daisy/command/arguments/Arguments.kt @@ -1,4 +1,4 @@ -@file:Suppress("unused") +@file:Suppress("unused") package cat.daisy.command.arguments @@ -7,29 +7,16 @@ import org.bukkit.GameMode import org.bukkit.Material import org.bukkit.OfflinePlayer import org.bukkit.World +import org.bukkit.command.CommandSender import org.bukkit.entity.EntityType import org.bukkit.entity.Player import java.time.Duration import java.util.UUID -// ═══════════════════════════════════════════════════════════════════════════════ -// CONSTANTS -// ═══════════════════════════════════════════════════════════════════════════════ - -/** Maximum allowed input length to prevent memory attacks */ private const val MAX_INPUT_LENGTH = 256 +private const val MAX_TEXT_LENGTH = 1024 +private val DURATION_PATTERN = Regex("""(?:(\d+)d)?(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?""") -/** Maximum allowed greedy string length */ -private const val MAX_GREEDY_LENGTH = 1024 - -// ═══════════════════════════════════════════════════════════════════════════════ -// RESULT TYPES -// ═══════════════════════════════════════════════════════════════════════════════ - -/** - * Sealed result type for argument parsing. - * Enables functional-style error handling. - */ sealed class ParseResult { data class Success( val value: T, @@ -45,404 +32,477 @@ sealed class ParseResult { is Failure -> this } - inline fun flatMap(transform: (T) -> ParseResult): ParseResult = - when (this) { - is Success -> transform(value) - is Failure -> this - } + companion object { + fun success(value: T): ParseResult = Success(value) - inline fun onSuccess(block: (T) -> Unit): ParseResult { - if (this is Success) block(value) - return this + fun failure(message: String): ParseResult = Failure(message) } +} - inline fun onFailure(block: (String) -> Unit): ParseResult { - if (this is Failure) block(error) - return this - } +interface DaisyPlatform { + fun findPlayer(name: String): Player? - fun getOrNull(): T? = (this as? Success)?.value + fun onlinePlayers(): Collection - fun getOrElse(default: @UnsafeVariance T): T = getOrNull() ?: default + fun findOfflinePlayer(name: String): OfflinePlayer? - fun getOrThrow(): T = getOrNull() ?: throw IllegalStateException((this as Failure).error) + fun findWorld(name: String): World? - val isSuccess: Boolean get() = this is Success - val isFailure: Boolean get() = this is Failure + fun worlds(): Collection +} - companion object { - fun success(value: T): ParseResult = Success(value) +object BukkitPlatform : DaisyPlatform { + override fun findPlayer(name: String): Player? = Bukkit.getPlayer(name) + + override fun onlinePlayers(): Collection = Bukkit.getOnlinePlayers() - fun failure(error: String): ParseResult = Failure(error) + override fun findOfflinePlayer(name: String): OfflinePlayer? { + @Suppress("DEPRECATION") + val player = Bukkit.getOfflinePlayer(name) + return player.takeIf { it.hasPlayedBefore() || it.isOnline } } + + override fun findWorld(name: String): World? = Bukkit.getWorld(name) + + override fun worlds(): Collection = Bukkit.getWorlds() +} + +data class ParseContext( + val sender: CommandSender, + val platform: DaisyPlatform, + val commandPath: List, + val argumentName: String, + val previousArguments: Map = emptyMap(), +) + +data class SuggestContext( + val sender: CommandSender, + val platform: DaisyPlatform, + val commandPath: List, + val argumentName: String, + val currentInput: String, + val previousArguments: Map, +) + +data class ValidationContext( + val sender: CommandSender, + val platform: DaisyPlatform, + val commandPath: List, + val argumentName: String, + val value: T, + val previousArguments: Map, +) + +interface DaisyParser { + val displayName: String + val greedy: Boolean get() = false + + fun parse( + input: String, + context: ParseContext, + ): ParseResult + + fun suggest(context: SuggestContext): List = emptyList() +} + +enum class ArgumentKind { + POSITIONAL, + OPTION, + FLAG, } -// ═══════════════════════════════════════════════════════════════════════════════ -// ARGUMENT DEFINITION -// ═══════════════════════════════════════════════════════════════════════════════ +internal object NoDefaultValue -/** - * Represents a command argument definition. - */ -sealed class ArgumentDef( +internal class MutableArgumentDefinition( + val slot: Int, + val name: String, + val parser: DaisyParser, + val kind: ArgumentKind, + val longName: String? = null, + val shortName: String? = null, + var optional: Boolean = false, + var defaultValue: Any? = NoDefaultValue, + var description: String = "", + var suggestions: (SuggestContext.() -> Iterable)? = null, + var validatorMessage: String? = null, + var validator: ((ValidationContext) -> Boolean)? = null, +) + +internal data class CompiledArgument( + val slot: Int, + val name: String, + val parser: DaisyParser, + val kind: ArgumentKind, + val longName: String?, + val shortName: String?, + val optional: Boolean, + val hasDefault: Boolean, + val defaultValue: Any?, + val description: String, + val suggestions: (SuggestContext.() -> Iterable)?, + val validatorMessage: String?, + val validator: ((ValidationContext) -> Boolean)?, +) + +class ArgumentRef internal constructor( + internal val definition: MutableArgumentDefinition, val name: String, - val optional: Boolean = false, ) { - class StringArg( - name: String, - optional: Boolean = false, - ) : ArgumentDef(name, optional) - - class GreedyStringArg( - name: String, - optional: Boolean = false, - ) : ArgumentDef(name, optional) - - class IntArg( - name: String, - val min: Int = Int.MIN_VALUE, - val max: Int = Int.MAX_VALUE, - optional: Boolean = false, - ) : ArgumentDef(name, optional) - - class LongArg( - name: String, - val min: Long = Long.MIN_VALUE, - val max: Long = Long.MAX_VALUE, - optional: Boolean = false, - ) : ArgumentDef(name, optional) - - class DoubleArg( - name: String, - val min: Double = Double.MIN_VALUE, - val max: Double = Double.MAX_VALUE, - optional: Boolean = false, - ) : ArgumentDef(name, optional) - - class FloatArg( - name: String, - val min: Float = Float.MIN_VALUE, - val max: Float = Float.MAX_VALUE, - optional: Boolean = false, - ) : ArgumentDef(name, optional) - - class BooleanArg( - name: String, - optional: Boolean = false, - ) : ArgumentDef(name, optional) - - class PlayerArg( - name: String, - optional: Boolean = false, - ) : ArgumentDef(name, optional) - - class OfflinePlayerArg( - name: String, - optional: Boolean = false, - ) : ArgumentDef(name, optional) - - class WorldArg( - name: String, - optional: Boolean = false, - ) : ArgumentDef(name, optional) - - class MaterialArg( - name: String, - optional: Boolean = false, - ) : ArgumentDef(name, optional) - - class GameModeArg( - name: String, - optional: Boolean = false, - ) : ArgumentDef(name, optional) - - class EntityTypeArg( - name: String, - optional: Boolean = false, - ) : ArgumentDef(name, optional) - - class UUIDArg( - name: String, - optional: Boolean = false, - ) : ArgumentDef(name, optional) - - class DurationArg( - name: String, - optional: Boolean = false, - ) : ArgumentDef(name, optional) - - class ChoiceArg( - name: String, - val choices: List, - optional: Boolean = false, - ) : ArgumentDef(name, optional) - - class EnumArg>( - name: String, - val enumClass: Class, - optional: Boolean = false, - ) : ArgumentDef(name, optional) - - class CustomArg( - name: String, - val parser: ArgParser, - optional: Boolean = false, - ) : ArgumentDef(name, optional) -} + @Suppress("UNCHECKED_CAST") + fun optional(): ArgumentRef { + definition.optional = true + return this as ArgumentRef + } -// ═══════════════════════════════════════════════════════════════════════════════ -// ARGUMENT PARSER INTERFACE -// ═══════════════════════════════════════════════════════════════════════════════ + fun default(value: T): ArgumentRef { + definition.optional = true + definition.defaultValue = value + return this + } -/** - * Type-safe argument parser with tab completion support. - */ -interface ArgParser { - fun parse(input: String): ParseResult + fun suggests(block: SuggestContext.() -> Iterable): ArgumentRef { + definition.suggestions = block + return this + } + + fun validate( + message: String? = null, + predicate: ValidationContext.() -> Boolean, + ): ArgumentRef { + definition.validatorMessage = message + @Suppress("UNCHECKED_CAST") + definition.validator = { predicate(it as ValidationContext) } + return this + } - fun complete(input: String): List = emptyList() + fun description(text: String): ArgumentRef { + definition.description = text + return this + } } -// ═══════════════════════════════════════════════════════════════════════════════ -// BUILT-IN PARSERS -// ═══════════════════════════════════════════════════════════════════════════════ +@Suppress("UNCHECKED_CAST") +fun ArgumentRef.optional(): ArgumentRef = optional() -/** - * Collection of optimized argument parsers. - */ object Parsers { - // ───────────────────────────────────────────────────────────────────────── - // PRIMITIVES - // ───────────────────────────────────────────────────────────────────────── - - val STRING = - object : ArgParser { - override fun parse(input: String): ParseResult { + val STRING: DaisyParser = + object : DaisyParser { + override val displayName: String = "string" + + override fun parse( + input: String, + context: ParseContext, + ): ParseResult { if (input.length > MAX_INPUT_LENGTH) { - return ParseResult.failure("Input too long (max $MAX_INPUT_LENGTH characters)") + return ParseResult.failure("Input is too long. Max $MAX_INPUT_LENGTH characters.") } return ParseResult.success(input) } } - /** Greedy string parser that captures all remaining arguments */ - val GREEDY_STRING = - object : ArgParser { - override fun parse(input: String): ParseResult { - if (input.length > MAX_GREEDY_LENGTH) { - return ParseResult.failure("Input too long (max $MAX_GREEDY_LENGTH characters)") + val TEXT: DaisyParser = + object : DaisyParser { + override val displayName: String = "text" + override val greedy: Boolean = true + + override fun parse( + input: String, + context: ParseContext, + ): ParseResult { + if (input.length > MAX_TEXT_LENGTH) { + return ParseResult.failure("Input is too long. Max $MAX_TEXT_LENGTH characters.") } return ParseResult.success(input) } } + val OPTION_TEXT: DaisyParser = STRING + fun int( min: Int = Int.MIN_VALUE, max: Int = Int.MAX_VALUE, - ) = object : ArgParser { - override fun parse(input: String): ParseResult { - val value = - input.toIntOrNull() - ?: return ParseResult.failure("'$input' is not a valid number") - return if (value in min..max) { - ParseResult.success(value) - } else { - ParseResult.failure("Number must be between $min and $max") + ): DaisyParser = + object : DaisyParser { + override val displayName: String = "int" + + override fun parse( + input: String, + context: ParseContext, + ): ParseResult { + val value = input.toIntOrNull() ?: return ParseResult.failure("'$input' is not a valid integer.") + return if (value in min..max) { + ParseResult.success(value) + } else { + ParseResult.failure("Value must be between $min and $max.") + } } - } - override fun complete(input: String) = if (input.isEmpty()) listOf("1", "5", "10", "50", "100") else emptyList() - } + override fun suggest(context: SuggestContext): List = + if (context.currentInput.isEmpty()) { + listOf("1", "5", "10", "50", "100") + } else { + emptyList() + } + } fun long( min: Long = Long.MIN_VALUE, max: Long = Long.MAX_VALUE, - ) = object : ArgParser { - override fun parse(input: String): ParseResult { - val value = - input.toLongOrNull() - ?: return ParseResult.failure("'$input' is not a valid number") - return if (value in min..max) { - ParseResult.success(value) - } else { - ParseResult.failure("Number must be between $min and $max") + ): DaisyParser = + object : DaisyParser { + override val displayName: String = "long" + + override fun parse( + input: String, + context: ParseContext, + ): ParseResult { + val value = input.toLongOrNull() ?: return ParseResult.failure("'$input' is not a valid long.") + return if (value in min..max) { + ParseResult.success(value) + } else { + ParseResult.failure("Value must be between $min and $max.") + } } } - } fun double( - min: Double = Double.MIN_VALUE, - max: Double = Double.MAX_VALUE, - ) = object : ArgParser { - override fun parse(input: String): ParseResult { - val value = - input.toDoubleOrNull() - ?: return ParseResult.failure("'$input' is not a valid decimal") - return if (value in min..max) { - ParseResult.success(value) - } else { - ParseResult.failure("Number must be between $min and $max") + min: Double = Double.NEGATIVE_INFINITY, + max: Double = Double.POSITIVE_INFINITY, + ): DaisyParser = + object : DaisyParser { + override val displayName: String = "double" + + override fun parse( + input: String, + context: ParseContext, + ): ParseResult { + val value = input.toDoubleOrNull() ?: return ParseResult.failure("'$input' is not a valid number.") + return if (value in min..max) { + ParseResult.success(value) + } else { + ParseResult.failure("Value must be between $min and $max.") + } } } - } fun float( - min: Float = Float.MIN_VALUE, - max: Float = Float.MAX_VALUE, - ) = object : ArgParser { - override fun parse(input: String): ParseResult { - val value = - input.toFloatOrNull() - ?: return ParseResult.failure("'$input' is not a valid decimal") - return if (value in min..max) { - ParseResult.success(value) - } else { - ParseResult.failure("Number must be between $min and $max") + min: Float = Float.NEGATIVE_INFINITY, + max: Float = Float.POSITIVE_INFINITY, + ): DaisyParser = + object : DaisyParser { + override val displayName: String = "float" + + override fun parse( + input: String, + context: ParseContext, + ): ParseResult { + val value = input.toFloatOrNull() ?: return ParseResult.failure("'$input' is not a valid number.") + return if (value in min..max) { + ParseResult.success(value) + } else { + ParseResult.failure("Value must be between $min and $max.") + } } } - } - val BOOLEAN = - object : ArgParser { - private val trueValues = setOf("true", "yes", "on", "1", "enable", "y") - private val falseValues = setOf("false", "no", "off", "0", "disable", "n") - - override fun parse(input: String): ParseResult { - val lower = input.lowercase() - return when { - lower in trueValues -> ParseResult.success(true) - lower in falseValues -> ParseResult.success(false) - else -> ParseResult.failure("'$input' is not a valid boolean (true/false)") + val BOOLEAN: DaisyParser = + object : DaisyParser { + override val displayName: String = "boolean" + + override fun parse( + input: String, + context: ParseContext, + ): ParseResult = + when (input.lowercase()) { + "true", "yes", "on", "1", "enable", "y" -> ParseResult.success(true) + "false", "no", "off", "0", "disable", "n" -> ParseResult.success(false) + else -> ParseResult.failure("'$input' is not a valid boolean.") } - } - - override fun complete(input: String) = listOf("true", "false").filter { it.startsWith(input, ignoreCase = true) } - } - // ───────────────────────────────────────────────────────────────────────── - // BUKKIT TYPES - // ───────────────────────────────────────────────────────────────────────── - - val PLAYER = - object : ArgParser { - override fun parse(input: String): ParseResult = - Bukkit.getPlayer(input)?.let { ParseResult.success(it) } - ?: ParseResult.failure("Player '$input' is not online") - - override fun complete(input: String) = - Bukkit - .getOnlinePlayers() - .asSequence() - .map { it.name } - .filter { it.startsWith(input, ignoreCase = true) } - .toList() + override fun suggest(context: SuggestContext): List = filterByInput(listOf("true", "false"), context.currentInput) } - val OFFLINE_PLAYER = - object : ArgParser { - override fun parse(input: String): ParseResult { - @Suppress("DEPRECATION") - val player = Bukkit.getOfflinePlayer(input) - return if (player.hasPlayedBefore() || player.isOnline) { - ParseResult.success(player) - } else { - ParseResult.failure("Player '$input' has never played here") + val PLAYER: DaisyParser = + object : DaisyParser { + override val displayName: String = "player" + + override fun parse( + input: String, + context: ParseContext, + ): ParseResult = + context.platform.findPlayer(input)?.let { ParseResult.success(it) } + ?: ParseResult.failure("Player '$input' is not online.") + + override fun suggest(context: SuggestContext): List { + val suggestions = ArrayList() + for (player in context.platform.onlinePlayers()) { + val name = player.name + if (name.startsWith(context.currentInput, ignoreCase = true)) { + suggestions += name + } } + return suggestions } + } - override fun complete(input: String) = PLAYER.complete(input) + val OFFLINE_PLAYER: DaisyParser = + object : DaisyParser { + override val displayName: String = "offline-player" + + override fun parse( + input: String, + context: ParseContext, + ): ParseResult = + context.platform.findOfflinePlayer(input)?.let { ParseResult.success(it) } + ?: ParseResult.failure("Player '$input' was not found.") + + override fun suggest(context: SuggestContext): List = PLAYER.suggest(context) } - val WORLD = - object : ArgParser { - override fun parse(input: String): ParseResult = - Bukkit.getWorld(input)?.let { ParseResult.success(it) } - ?: ParseResult.failure("World '$input' does not exist") - - override fun complete(input: String) = - Bukkit - .getWorlds() - .asSequence() - .map { it.name } - .filter { it.startsWith(input, ignoreCase = true) } - .toList() + val WORLD: DaisyParser = + object : DaisyParser { + override val displayName: String = "world" + + override fun parse( + input: String, + context: ParseContext, + ): ParseResult = + context.platform.findWorld(input)?.let { ParseResult.success(it) } + ?: ParseResult.failure("World '$input' was not found.") + + override fun suggest(context: SuggestContext): List { + val suggestions = ArrayList() + for (world in context.platform.worlds()) { + val name = world.name + if (name.startsWith(context.currentInput, ignoreCase = true)) { + suggestions += name + } + } + return suggestions + } } - val MATERIAL = - object : ArgParser { - override fun parse(input: String): ParseResult = + val MATERIAL: DaisyParser = + object : DaisyParser { + override val displayName: String = "material" + + override fun parse( + input: String, + context: ParseContext, + ): ParseResult = Material.matchMaterial(input)?.let { ParseResult.success(it) } - ?: ParseResult.failure("Material '$input' does not exist") - - override fun complete(input: String): List { - val upper = input.uppercase() - return Material.entries - .asSequence() - .filter { it.name.startsWith(upper) } - .take(30) - .map { it.name.lowercase() } - .toList() + ?: ParseResult.failure("Material '$input' was not found.") + + override fun suggest(context: SuggestContext): List { + val suggestions = ArrayList() + for (material in Material.entries) { + val name = material.name.lowercase() + if (name.startsWith(context.currentInput, ignoreCase = true)) { + suggestions += name + if (suggestions.size == 30) { + break + } + } + } + return suggestions } } - val GAMEMODE = - object : ArgParser { - override fun parse(input: String): ParseResult = - GameMode.entries - .find { it.name.equals(input, ignoreCase = true) } - ?.let { ParseResult.success(it) } - ?: ParseResult.failure("Invalid gamemode '$input'") - - override fun complete(input: String) = - GameMode.entries - .map { it.name.lowercase() } - .filter { it.startsWith(input.lowercase()) } + val GAME_MODE: DaisyParser = + object : DaisyParser { + override val displayName: String = "gamemode" + + override fun parse( + input: String, + context: ParseContext, + ): ParseResult { + for (mode in GameMode.entries) { + if (mode.name.equals(input, ignoreCase = true)) { + return ParseResult.success(mode) + } + } + return ParseResult.failure("Game mode '$input' is invalid.") + } + + override fun suggest(context: SuggestContext): List { + val suggestions = ArrayList() + for (mode in GameMode.entries) { + val name = mode.name.lowercase() + if (name.startsWith(context.currentInput, ignoreCase = true)) { + suggestions += name + } + } + return suggestions + } } - val ENTITY_TYPE = - object : ArgParser { - override fun parse(input: String): ParseResult = - EntityType.entries - .find { it.name.equals(input, ignoreCase = true) } - ?.let { ParseResult.success(it) } - ?: ParseResult.failure("Invalid entity type '$input'") - - override fun complete(input: String) = - EntityType.entries - .filter { it.isSpawnable } - .map { it.name.lowercase() } - .filter { it.startsWith(input.lowercase()) } - .take(30) + val ENTITY_TYPE: DaisyParser = + object : DaisyParser { + override val displayName: String = "entity-type" + + override fun parse( + input: String, + context: ParseContext, + ): ParseResult { + for (type in EntityType.entries) { + if (type.name.equals(input, ignoreCase = true)) { + return ParseResult.success(type) + } + } + return ParseResult.failure("Entity type '$input' is invalid.") + } + + override fun suggest(context: SuggestContext): List { + val suggestions = ArrayList() + for (type in EntityType.entries) { + if (!type.isSpawnable) { + continue + } + val name = type.name.lowercase() + if (name.startsWith(context.currentInput, ignoreCase = true)) { + suggestions += name + if (suggestions.size == 30) { + break + } + } + } + return suggestions + } } - val UUID_PARSER = - object : ArgParser { - override fun parse(input: String): ParseResult = + val UUID_PARSER: DaisyParser = + object : DaisyParser { + override val displayName: String = "uuid" + + override fun parse( + input: String, + context: ParseContext, + ): ParseResult = try { ParseResult.success(UUID.fromString(input)) } catch (_: IllegalArgumentException) { - ParseResult.failure("'$input' is not a valid UUID") + ParseResult.failure("'$input' is not a valid UUID.") } } - /** - * Duration parser - supports formats like: 1d, 2h, 30m, 45s, 1d2h30m - */ - val DURATION = - object : ArgParser { - private val pattern = Regex("""(?:(\d+)d)?(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?""") + val DURATION: DaisyParser = + object : DaisyParser { + override val displayName: String = "duration" - override fun parse(input: String): ParseResult { + override fun parse( + input: String, + context: ParseContext, + ): ParseResult { val match = - pattern.matchEntire(input.lowercase()) - ?: return ParseResult.failure("Invalid duration format. Use: 1d2h30m45s") - val days = match.groupValues[1].toLongOrNull() ?: 0 - val hours = match.groupValues[2].toLongOrNull() ?: 0 - val minutes = match.groupValues[3].toLongOrNull() ?: 0 - val seconds = match.groupValues[4].toLongOrNull() ?: 0 + DURATION_PATTERN.matchEntire(input.lowercase()) + ?: return ParseResult.failure("Invalid duration. Use values like 30m, 2h, or 1d2h.") + val days = match.groupValues[1].toLongOrNull() ?: 0L + val hours = match.groupValues[2].toLongOrNull() ?: 0L + val minutes = match.groupValues[3].toLongOrNull() ?: 0L + val seconds = match.groupValues[4].toLongOrNull() ?: 0L if (days == 0L && hours == 0L && minutes == 0L && seconds == 0L) { - return ParseResult.failure("Invalid duration. Use formats like: 1d, 2h, 30m, 45s") + return ParseResult.failure("Duration must be greater than zero.") } return ParseResult.success( @@ -454,49 +514,70 @@ object Parsers { ) } - override fun complete(input: String) = listOf("1h", "30m", "1d", "12h", "7d") + override fun suggest(context: SuggestContext): List = + filterByInput(listOf("30m", "1h", "12h", "1d", "7d"), context.currentInput) } - // ───────────────────────────────────────────────────────────────────────── - // GENERIC PARSERS - // ───────────────────────────────────────────────────────────────────────── + fun choice(vararg options: String): DaisyParser { + val byKey = LinkedHashMap(options.size) + for (option in options) { + byKey[option.lowercase()] = option + } - /** Create an enum parser */ - inline fun > enum() = - object : ArgParser { - private val values = enumValues() - private val names = values.map { it.name.lowercase() } + return object : DaisyParser { + override val displayName: String = "choice" - override fun parse(input: String): ParseResult = - values - .find { it.name.equals(input, ignoreCase = true) } - ?.let { ParseResult.success(it) } - ?: ParseResult.failure("Invalid option '$input'. Available: ${names.joinToString()}") + override fun parse( + input: String, + context: ParseContext, + ): ParseResult = + byKey[input.lowercase()]?.let { ParseResult.success(it) } + ?: ParseResult.failure("Expected one of: ${options.joinToString(", ")}.") - override fun complete(input: String) = names.filter { it.startsWith(input.lowercase()) } + override fun suggest(context: SuggestContext): List = filterByInput(options.asList(), context.currentInput) } + } - /** Fixed choices parser */ - fun choice(vararg options: String) = - object : ArgParser { - private val optionSet = options.map { it.lowercase() }.toSet() + inline fun > enum(): DaisyParser { + val entries = enumValues() + val byKey = LinkedHashMap(entries.size) + for (entry in entries) { + byKey[entry.name.lowercase()] = entry + } - override fun parse(input: String): ParseResult = - if (input.lowercase() in optionSet) { - ParseResult.success(input) - } else { - ParseResult.failure("Invalid choice. Options: ${options.joinToString()}") + return object : DaisyParser { + override val displayName: String = "enum" + + override fun parse( + input: String, + context: ParseContext, + ): ParseResult = + byKey[input.lowercase()]?.let { ParseResult.success(it) } + ?: ParseResult.failure("Expected one of: ${entries.joinToString(", ") { it.name.lowercase() }}.") + + override fun suggest(context: SuggestContext): List { + val suggestions = ArrayList(entries.size) + for (entry in entries) { + val name = entry.name.lowercase() + if (name.startsWith(context.currentInput, ignoreCase = true)) { + suggestions += name + } } - - override fun complete(input: String) = options.filter { it.startsWith(input, ignoreCase = true) }.toList() + return suggestions + } } + } +} - /** Positive integer (> 0) */ - val POSITIVE_INT = int(min = 1) - - /** Non-negative integer (>= 0) */ - val NON_NEGATIVE_INT = int(min = 0) - - /** Percentage (0-100) */ - val PERCENTAGE = int(min = 0, max = 100) +internal fun filterByInput( + values: Collection, + currentInput: String, +): List { + val suggestions = ArrayList(values.size) + for (value in values) { + if (value.startsWith(currentInput, ignoreCase = true)) { + suggestions += value + } + } + return suggestions } diff --git a/src/main/kotlin/cat/daisy/command/context/Context.kt b/src/main/kotlin/cat/daisy/command/context/Context.kt index 72c110a..ca589e7 100644 --- a/src/main/kotlin/cat/daisy/command/context/Context.kt +++ b/src/main/kotlin/cat/daisy/command/context/Context.kt @@ -1,517 +1,101 @@ -@file:Suppress("unused") +@file:Suppress("unused") package cat.daisy.command.context -import cat.daisy.command.arguments.ArgParser -import cat.daisy.command.arguments.ParseResult -import cat.daisy.command.cooldown.DaisyCooldowns +import cat.daisy.command.arguments.ArgumentRef +import cat.daisy.command.core.CommandRuntime import cat.daisy.command.text.DaisyText.mm import net.kyori.adventure.text.Component -import net.kyori.adventure.title.Title -import org.bukkit.Bukkit -import org.bukkit.Sound import org.bukkit.command.CommandSender +import org.bukkit.command.ConsoleCommandSender import org.bukkit.entity.Player -import java.time.Duration -// ═══════════════════════════════════════════════════════════════════════════════ -// COMMAND CONTEXT -// ═══════════════════════════════════════════════════════════════════════════════ - -/** - * Main execution context for commands. - * Provides access to sender, arguments, and utility methods. - */ -class CommandContext( - val sender: CommandSender, - val args: List, - val namedArgs: Map = emptyMap(), - val label: String = "", +internal class ResolvedArguments( + private val valuesBySlot: Array, + private val valuesByName: Map, ) { - val player: Player? get() = sender as? Player - val isPlayer: Boolean get() = sender is Player - val isConsole: Boolean get() = !isPlayer - - // ───────────────────────────────────────────────────────────────────────── - // NAMED ARGUMENT ACCESS - // ───────────────────────────────────────────────────────────────────────── - - operator fun get(key: String): Any? = namedArgs[key] - @Suppress("UNCHECKED_CAST") - fun getArg(key: String): T? = namedArgs[key] as? T - - fun getString(key: String): String? = namedArgs[key] as? String - - fun getInt(key: String): Int? = namedArgs[key] as? Int - - fun getLong(key: String): Long? = namedArgs[key] as? Long - - fun getDouble(key: String): Double? = namedArgs[key] as? Double - - fun getFloat(key: String): Float? = namedArgs[key] as? Float - - fun getBoolean(key: String): Boolean? = namedArgs[key] as? Boolean - - fun getPlayer(key: String): Player? = namedArgs[key] as? Player - - // ───────────────────────────────────────────────────────────────────────── - // POSITIONAL ARGUMENT ACCESS - // ───────────────────────────────────────────────────────────────────────── - - fun arg(index: Int): String? = args.getOrNull(index) - - fun argOr( - index: Int, - default: String, - ): String = args.getOrElse(index) { default } - - fun argInt(index: Int): Int? = arg(index)?.toIntOrNull() - - fun argLong(index: Int): Long? = arg(index)?.toLongOrNull() - - fun argDouble(index: Int): Double? = arg(index)?.toDoubleOrNull() - - fun argFloat(index: Int): Float? = arg(index)?.toFloatOrNull() - - fun argBoolean(index: Int): Boolean? = - arg(index)?.lowercase()?.let { - when (it) { - "true", "yes", "on", "1" -> true - "false", "no", "off", "0" -> false - else -> null - } - } - - fun argPlayer(index: Int): Player? = arg(index)?.let { Bukkit.getPlayer(it) } - - fun joinArgs(from: Int = 0): String = args.drop(from).joinToString(" ") - - val argCount: Int get() = args.size - - // ───────────────────────────────────────────────────────────────────────── - // COOLDOWN UTILITIES - // ───────────────────────────────────────────────────────────────────────── - - fun isOnCooldown( - command: String, - seconds: Int, - bypassPermission: String? = null, - ): Boolean { - val p = player ?: return false - return DaisyCooldowns.isOnCooldown(p, command, seconds, bypassPermission) - } - - fun getCooldown( - command: String, - seconds: Int, - ): Long { - val p = player ?: return 0 - return DaisyCooldowns.getRemainingCooldown(p, command, seconds) - } - - fun checkCooldown( - command: String, - seconds: Int, - ): Long { - val p = player ?: return 0 - return DaisyCooldowns.checkCooldown(p, command, seconds) - } - - // ───────────────────────────────────────────────────────────────────────── - // MINIMESSAGE RESPONSES - // ───────────────────────────────────────────────────────────────────────── - - fun send(message: String) = sender.sendMessage(message.mm()) - - fun send(component: Component) = sender.sendMessage(component) - - // Beautiful prefixed messages with emojis - fun reply(message: String) = send("» $message") + fun get(ref: ArgumentRef): T = valuesBySlot[ref.definition.slot] as T - fun success(message: String) = send("<#2ecc71>✔ $message") - - fun error(message: String) = send("<#e74c3c>✖ $message") - - fun warn(message: String) = send("<#f39c12>⚠ $message") - - fun info(message: String) = send("<#3498db>ℹ $message") - - fun notify(message: String) = send("<#9b59b6>★ $message") - - fun tip(message: String) = send("<#1abc9c>💡 $message") - - fun currency(message: String) = send("<#f1c40f>✧ $message") - - fun heart(message: String) = send("<#e74c3c>❤ $message") - - fun team(message: String) = send("<#3498db>⚑ $message") - - fun broadcast(message: String) = Bukkit.broadcast("<#9b59b6>📢 $message".mm()) - - // ───────────────────────────────────────────────────────────────────────── - // PLAYER UTILITIES - // ───────────────────────────────────────────────────────────────────────── - - fun asPlayer(): Player? = - player ?: run { - error("This command requires a player!") - null - } - - inline fun requirePlayer(block: PlayerContext.() -> Unit) { - asPlayer()?.let { PlayerContext(it, args, namedArgs, label).block() } - } - - inline fun requirePermission( - permission: String, - block: () -> Unit, - ) { - if (sender.hasPermission(permission)) { - block() - } else { - error("You don't have permission to do this!") - } - } - - inline fun requireArgs( - count: Int, - usageMsg: String = "Not enough arguments!", - block: () -> Unit, - ) { - if (args.size >= count) { - block() - } else { - error(usageMsg) - } - } - - inline fun withArg( - index: Int, - parser: (String) -> T?, - errorMsg: String = "Invalid argument!", - block: (T) -> Unit, - ) { - val raw = arg(index) - if (raw == null) { - error("Missing argument at position ${index + 1}!") - return - } - val parsed = parser(raw) - if (parsed == null) { - error(errorMsg) - return - } - block(parsed) - } - - inline fun withPlayer( - index: Int, - block: (Player) -> Unit, - ) = withArg(index, Bukkit::getPlayer, "Player not found!", block) - - inline fun withInt( - index: Int, - block: (Int) -> Unit, - ) = withArg(index, String::toIntOrNull, "Invalid number!", block) - - inline fun withDouble( - index: Int, - block: (Double) -> Unit, - ) = withArg(index, String::toDoubleOrNull, "Invalid number!", block) - - // ───────────────────────────────────────────────────────────────────────── - // PARSER INTEGRATION - // ───────────────────────────────────────────────────────────────────────── - - fun parse( - index: Int, - parser: ArgParser, - ): ParseResult { - val input = arg(index) ?: return ParseResult.Failure("Missing argument at position ${index + 1}") - return parser.parse(input) - } - - inline fun withParsed( - index: Int, - parser: ArgParser, - block: (T) -> Unit, - ) { - parse(index, parser).onSuccess(block).onFailure { error(it) } - } + fun byName(name: String): Any? = valuesByName[name] } -// ═══════════════════════════════════════════════════════════════════════════════ -// PLAYER CONTEXT -// ═══════════════════════════════════════════════════════════════════════════════ +internal object AbortExecution : RuntimeException(null, null, false, false) -/** - * Extended context for player-only commands. - * Includes sounds, titles, action bars, and more. - */ -class PlayerContext( - val player: Player, +open class CommandContext internal constructor( + val sender: CommandSender, + val label: String, + val path: List, val args: List, - val namedArgs: Map = emptyMap(), - val label: String = "", + private val resolvedArguments: ResolvedArguments, + private val runtime: CommandRuntime, ) { - val sender: CommandSender get() = player - - // ───────────────────────────────────────────────────────────────────────── - // NAMED ARGUMENT ACCESS - // ───────────────────────────────────────────────────────────────────────── - - operator fun get(key: String): Any? = namedArgs[key] + private var successful = true - @Suppress("UNCHECKED_CAST") - fun getArg(key: String): T? = namedArgs[key] as? T - - fun getString(key: String): String? = namedArgs[key] as? String - - fun getInt(key: String): Int? = namedArgs[key] as? Int - - fun getLong(key: String): Long? = namedArgs[key] as? Long - - fun getDouble(key: String): Double? = namedArgs[key] as? Double - - fun getBoolean(key: String): Boolean? = namedArgs[key] as? Boolean - - fun getPlayer(key: String): Player? = namedArgs[key] as? Player - - // ───────────────────────────────────────────────────────────────────────── - // POSITIONAL ARGUMENT ACCESS - // ───────────────────────────────────────────────────────────────────────── - - fun arg(index: Int): String? = args.getOrNull(index) - - fun argOr( - index: Int, - default: String, - ): String = args.getOrElse(index) { default } - - fun argInt(index: Int): Int? = arg(index)?.toIntOrNull() - - fun argLong(index: Int): Long? = arg(index)?.toLongOrNull() - - fun argDouble(index: Int): Double? = arg(index)?.toDoubleOrNull() - - fun argPlayer(index: Int): Player? = arg(index)?.let { Bukkit.getPlayer(it) } - - fun joinArgs(from: Int = 0): String = args.drop(from).joinToString(" ") - - val argCount: Int get() = args.size - - // ───────────────────────────────────────────────────────────────────────── - // COOLDOWN UTILITIES - // ───────────────────────────────────────────────────────────────────────── - - fun isOnCooldown( - command: String, - seconds: Int, - bypassPermission: String? = null, - ): Boolean = DaisyCooldowns.isOnCooldown(player, command, seconds, bypassPermission) + val isPlayer: Boolean + get() = sender is Player - fun getCooldown( - command: String, - seconds: Int, - ): Long = DaisyCooldowns.getRemainingCooldown(player, command, seconds) + val isConsole: Boolean + get() = sender is ConsoleCommandSender - // ───────────────────────────────────────────────────────────────────────── - // MINIMESSAGE RESPONSES - // ───────────────────────────────────────────────────────────────────────── + open val player: Player + get() = sender as? Player ?: error("This command is not executing as a player.") - fun send(message: String) = player.sendMessage(message.mm()) + open val console: ConsoleCommandSender + get() = sender as? ConsoleCommandSender ?: error("This command is not executing from the console.") - fun send(component: Component) = player.sendMessage(component) + internal fun wasSuccessful(): Boolean = successful - // Beautiful prefixed messages with emojis - fun reply(message: String) = send("» $message") - - fun success(message: String) = send("<#2ecc71>✔ $message") - - fun error(message: String) = send("<#e74c3c>✖ $message") - - fun warn(message: String) = send("<#f39c12>⚠ $message") - - fun info(message: String) = send("<#3498db>ℹ $message") - - fun notify(message: String) = send("<#9b59b6>★ $message") - - fun tip(message: String) = send("<#1abc9c>💡 $message") - - fun currency(message: String) = send("<#f1c40f>✧ $message") - - fun heart(message: String) = send("<#e74c3c>❤ $message") - - fun team(message: String) = send("<#3498db>⚑ $message") - - // ───────────────────────────────────────────────────────────────────────── - // PLAYER-EXCLUSIVE FEATURES - // ───────────────────────────────────────────────────────────────────────── - - fun actionBar(message: String) = player.sendActionBar(message.mm()) - - fun title( - title: String = "", - subtitle: String = "", - fadeIn: Duration = Duration.ofMillis(500), - stay: Duration = Duration.ofSeconds(3), - fadeOut: Duration = Duration.ofMillis(500), - ) { - player.showTitle( - Title.title( - title.mm(), - subtitle.mm(), - Title.Times.times(fadeIn, stay, fadeOut), - ), - ) + fun reply(message: String) { + sendWithPrefix(message) } - fun sound( - sound: Sound, - volume: Float = 1f, - pitch: Float = 1f, - ) { - player.playSound(player.location, sound, volume, pitch) + fun reply(component: Component) { + sender.sendMessage(component) } - fun successWithSound( - message: String, - sound: Sound = Sound.ENTITY_PLAYER_LEVELUP, - ) { - success(message) - sound(sound, 0.5f, 1.5f) + fun fail(message: String): Nothing { + successful = false + sendWithPrefix(message) + throw AbortExecution } - fun errorWithSound( - message: String, - sound: Sound = Sound.ENTITY_VILLAGER_NO, - ) { - error(message) - sound(sound, 0.5f, 1f) - } + fun get(ref: ArgumentRef): T = resolvedArguments.get(ref) - fun infoWithSound( - message: String, - sound: Sound = Sound.BLOCK_NOTE_BLOCK_PLING, - ) { - info(message) - sound(sound, 0.5f, 1.2f) - } + operator fun ArgumentRef.invoke(): T = get(this) - inline fun requirePermission( - permission: String, - block: () -> Unit, - ) { - if (player.hasPermission(permission)) { - block() - } else { - error("You don't have permission to do this!") - } + internal fun sendFramework(message: String) { + successful = false + sendWithPrefix(message) } - inline fun requireArgs( - count: Int, - usageMsg: String = "Not enough arguments!", - block: () -> Unit, + internal fun logFailure( + message: String, + throwable: Throwable, ) { - if (args.size >= count) { - block() - } else { - error(usageMsg) - } + runtime.logger.severe(message) + runtime.logger.severe(throwable.stackTraceToString()) } - // ───────────────────────────────────────────────────────────────────────── - // PARSER INTEGRATION - // ───────────────────────────────────────────────────────────────────────── - - fun parse( - index: Int, - parser: ArgParser, - ): ParseResult { - val input = arg(index) ?: return ParseResult.Failure("Missing argument at position ${index + 1}") - return parser.parse(input) + private fun sendWithPrefix(message: String) { + sender.sendMessage((runtime.config.messages.prefix + message).mm()) } - - inline fun withParsed( - index: Int, - parser: ArgParser, - block: (T) -> Unit, - ) { - parse(index, parser).onSuccess(block).onFailure { error(it) } - } -} - -// ═══════════════════════════════════════════════════════════════════════════════ -// TAB CONTEXT -// ═══════════════════════════════════════════════════════════════════════════════ - -/** - * Context for tab completion. - */ -class TabContext( - val sender: CommandSender, - val args: List, -) { - val player: Player? get() = sender as? Player - val isPlayer: Boolean get() = sender is Player - val currentArg: String get() = args.lastOrNull() ?: "" - val argIndex: Int get() = (args.size - 1).coerceAtLeast(0) - - fun filter(vararg options: String): List = options.filter { it.startsWith(currentArg, ignoreCase = true) } - - fun filter(options: Collection): List = options.filter { it.startsWith(currentArg, ignoreCase = true) } - - fun players(): List = - Bukkit - .getOnlinePlayers() - .asSequence() - .map { it.name } - .filter { it.startsWith(currentArg, ignoreCase = true) } - .toList() - - fun worlds(): List = - Bukkit - .getWorlds() - .asSequence() - .map { it.name } - .filter { it.startsWith(currentArg, ignoreCase = true) } - .toList() - - fun none(): List = emptyList() - - fun combine(vararg sources: () -> List): List = sources.flatMap { it() }.distinct() - - inline fun whenArg( - targetIndex: Int, - block: () -> List, - ): List = if (argIndex == targetIndex) block() else emptyList() - - inline fun byIndex(block: TabIndexBuilder.() -> Unit): List = TabIndexBuilder(this).apply(block).resolve() - - fun complete( - index: Int, - parser: ArgParser, - ): List = if (argIndex == index) parser.complete(currentArg) else emptyList() } -class TabIndexBuilder( - private val ctx: TabContext, -) { - private val handlers = mutableMapOf List>() - private var defaultHandler: (() -> List)? = null - - fun at( - index: Int, - block: TabContext.() -> List, - ) { - handlers[index] = { ctx.block() } - } - - fun default(block: TabContext.() -> List) { - defaultHandler = { ctx.block() } - } - - fun resolve(): List = handlers[ctx.argIndex]?.invoke() ?: defaultHandler?.invoke() ?: emptyList() -} +class PlayerCommandContext internal constructor( + override val player: Player, + label: String, + path: List, + args: List, + resolvedArguments: ResolvedArguments, + runtime: CommandRuntime, +) : CommandContext(player, label, path, args, resolvedArguments, runtime) + +class ConsoleCommandContext internal constructor( + override val console: ConsoleCommandSender, + label: String, + path: List, + args: List, + resolvedArguments: ResolvedArguments, + runtime: CommandRuntime, +) : CommandContext(console, label, path, args, resolvedArguments, runtime) diff --git a/src/main/kotlin/cat/daisy/command/cooldown/DaisyCooldowns.kt b/src/main/kotlin/cat/daisy/command/cooldown/DaisyCooldowns.kt index c9a5a79..06d8d2d 100644 --- a/src/main/kotlin/cat/daisy/command/cooldown/DaisyCooldowns.kt +++ b/src/main/kotlin/cat/daisy/command/cooldown/DaisyCooldowns.kt @@ -1,179 +1,75 @@ -@file:Suppress("unused") +@file:Suppress("unused") package cat.daisy.command.cooldown import org.bukkit.entity.Player +import java.time.Duration +import java.time.Instant import java.util.UUID import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.TimeUnit -/** - * DaisyCommand Cooldown Manager - * - * Thread-safe cooldown system with support for: - * - Per-command cooldowns - * - Per-player cooldowns - * - Global cooldowns - * - Cooldown bypass permissions - * - Remaining time queries - */ object DaisyCooldowns { - private val cooldowns = ConcurrentHashMap>() - private val globalCooldowns = ConcurrentHashMap() + private val playerCooldowns = ConcurrentHashMap>() + private val globalCooldowns = ConcurrentHashMap() - // ═══════════════════════════════════════════════════════════════════════════════ - // PLAYER COOLDOWNS - // ═══════════════════════════════════════════════════════════════════════════════ - - /** - * Get remaining cooldown time in seconds. - * Returns 0 if not on cooldown. - * Automatically sets the cooldown if not on cooldown. - */ - fun getRemainingCooldown( + fun remaining( player: Player, - command: String, - cooldownSeconds: Int, - ): Long { - val playerCooldowns = cooldowns.computeIfAbsent(player.uniqueId) { ConcurrentHashMap() } - val lastUsage = playerCooldowns[command] ?: 0L - val currentTime = System.currentTimeMillis() - val cooldownMillis = TimeUnit.SECONDS.toMillis(cooldownSeconds.toLong()) - - return if (currentTime - lastUsage < cooldownMillis) { - (lastUsage + cooldownMillis - currentTime) / 1000 - } else { - playerCooldowns[command] = currentTime - 0 - } - } - - /** - * Check if player is on cooldown without setting a new cooldown. - */ - fun checkCooldown( - player: Player, - command: String, - cooldownSeconds: Int, - ): Long { - val playerCooldowns = cooldowns[player.uniqueId] ?: return 0L - val lastUsage = playerCooldowns[command] ?: return 0L - val currentTime = System.currentTimeMillis() - val cooldownMillis = TimeUnit.SECONDS.toMillis(cooldownSeconds.toLong()) - - return if (currentTime - lastUsage < cooldownMillis) { - (lastUsage + cooldownMillis - currentTime) / 1000 - } else { - 0 - } - } + key: String, + duration: Duration, + ): Duration = remaining(player.uniqueId, key, duration) - /** - * Check if player is on cooldown. - * @param bypassPermission Optional permission to bypass cooldown - * @return true if on cooldown and should be blocked - */ - fun isOnCooldown( - player: Player, - command: String, - cooldownSeconds: Int, - bypassPermission: String? = null, - ): Boolean { - if (bypassPermission != null && player.hasPermission(bypassPermission)) return false - return getRemainingCooldown(player, command, cooldownSeconds) > 0 + fun remaining( + playerId: UUID, + key: String, + duration: Duration, + ): Duration { + val usedAt = playerCooldowns[playerId]?.get(key) ?: return Duration.ZERO + val remaining = Duration.between(Instant.now(), usedAt.plus(duration)) + return remaining.takeIf { !it.isNegative && !it.isZero } ?: Duration.ZERO } - /** - * Set a cooldown manually for a player. - */ - fun setCooldown( + fun set( player: Player, - command: String, + key: String, ) { - val playerCooldowns = cooldowns.computeIfAbsent(player.uniqueId) { ConcurrentHashMap() } - playerCooldowns[command] = System.currentTimeMillis() + set(player.uniqueId, key) } - /** - * Reset a specific cooldown for a player. - */ - fun resetCooldown( - uuid: UUID, - command: String, + fun set( + playerId: UUID, + key: String, ) { - cooldowns[uuid]?.remove(command) - } - - /** - * Clear all cooldowns for a player. - */ - fun clearCooldowns(uuid: UUID) { - cooldowns.remove(uuid) + playerCooldowns.computeIfAbsent(playerId) { ConcurrentHashMap() }[key] = Instant.now() } - /** - * Clear all player cooldowns. - */ - fun clearAll() { - cooldowns.clear() + fun clear(playerId: UUID) { + playerCooldowns.remove(playerId) } - // ═══════════════════════════════════════════════════════════════════════════════ - // GLOBAL COOLDOWNS - // ═══════════════════════════════════════════════════════════════════════════════ - - /** - * Check global cooldown (shared across all players). - */ - fun getGlobalRemainingCooldown( + fun remainingGlobal( key: String, - cooldownSeconds: Int, - ): Long { - val lastUsage = globalCooldowns[key] ?: 0L - val currentTime = System.currentTimeMillis() - val cooldownMillis = TimeUnit.SECONDS.toMillis(cooldownSeconds.toLong()) - - return if (currentTime - lastUsage < cooldownMillis) { - (lastUsage + cooldownMillis - currentTime) / 1000 - } else { - globalCooldowns[key] = currentTime - 0 - } + duration: Duration, + ): Duration { + val usedAt = globalCooldowns[key] ?: return Duration.ZERO + val remaining = Duration.between(Instant.now(), usedAt.plus(duration)) + return remaining.takeIf { !it.isNegative && !it.isZero } ?: Duration.ZERO } - /** - * Check if a global cooldown is active. - */ - fun isGlobalCooldown( - key: String, - cooldownSeconds: Int, - ): Boolean = getGlobalRemainingCooldown(key, cooldownSeconds) > 0 - - /** - * Reset a global cooldown. - */ - fun resetGlobalCooldown(key: String) { - globalCooldowns.remove(key) + fun setGlobal(key: String) { + globalCooldowns[key] = Instant.now() } - /** - * Clear all global cooldowns. - */ - fun clearGlobalCooldowns() { + fun clearAll() { + playerCooldowns.clear() globalCooldowns.clear() } - // ═══════════════════════════════════════════════════════════════════════════════ - // FORMATTING UTILITIES - // ═══════════════════════════════════════════════════════════════════════════════ - - /** - * Format remaining seconds to human-readable string. - */ - fun formatCooldown(seconds: Long): String = - when { - seconds < 60 -> "${seconds}s" - seconds < 3600 -> "${seconds / 60}m ${seconds % 60}s" - else -> "${seconds / 3600}h ${(seconds % 3600) / 60}m" + fun format(duration: Duration): String { + val totalSeconds = duration.seconds + return when { + totalSeconds < 60 -> "${totalSeconds}s" + totalSeconds < 3600 -> "${totalSeconds / 60}m ${totalSeconds % 60}s" + else -> "${totalSeconds / 3600}h ${(totalSeconds % 3600) / 60}m" } + } } diff --git a/src/main/kotlin/cat/daisy/command/core/CommandRuntime.kt b/src/main/kotlin/cat/daisy/command/core/CommandRuntime.kt new file mode 100644 index 0000000..4568fd3 --- /dev/null +++ b/src/main/kotlin/cat/daisy/command/core/CommandRuntime.kt @@ -0,0 +1,1227 @@ +@file:Suppress("unused") + +package cat.daisy.command.core + +import cat.daisy.command.arguments.ArgumentKind +import cat.daisy.command.arguments.BukkitPlatform +import cat.daisy.command.arguments.CompiledArgument +import cat.daisy.command.arguments.DaisyPlatform +import cat.daisy.command.arguments.ParseContext +import cat.daisy.command.arguments.ParseResult +import cat.daisy.command.arguments.SuggestContext +import cat.daisy.command.arguments.ValidationContext +import cat.daisy.command.context.AbortExecution +import cat.daisy.command.context.CommandContext +import cat.daisy.command.context.ConsoleCommandContext +import cat.daisy.command.context.PlayerCommandContext +import cat.daisy.command.context.ResolvedArguments +import cat.daisy.command.cooldown.DaisyCooldowns +import cat.daisy.command.text.DaisyText.mm +import org.bukkit.command.CommandSender +import org.bukkit.command.ConsoleCommandSender +import org.bukkit.entity.Player +import java.time.Duration +import java.util.LinkedHashMap +import java.util.logging.Logger + +private val HELP_ALIASES = setOf("help", "?") +private val COMMAND_NAME_PATTERN = Regex("^[a-z0-9][a-z0-9_-]*$") + +enum class SenderConstraint { + ANY, + PLAYER_ONLY, + CONSOLE_ONLY, +} + +data class CooldownSpec( + val duration: Duration, + val bypassPermission: String? = null, + val message: String? = null, +) + +internal class RequirementSpec( + val message: String?, + val check: CommandContext.() -> Boolean, +) + +internal sealed interface HandlerSpec + +internal class AnyHandler( + val block: CommandContext.() -> Unit, +) : HandlerSpec + +internal class PlayerHandler( + val block: PlayerCommandContext.() -> Unit, +) : HandlerSpec + +internal class ConsoleHandler( + val block: ConsoleCommandContext.() -> Unit, +) : HandlerSpec + +internal data class CommandNodeSpec( + val name: String, + val description: String, + val aliases: List, + val permission: String?, + val senderConstraint: SenderConstraint, + val cooldown: CooldownSpec?, + val arguments: List, + val requirements: List, + val children: List, + val handler: HandlerSpec?, +) + +class CommandSpec internal constructor( + val name: String, + val description: String, + val aliases: List, + internal val permission: String?, + internal val senderConstraint: SenderConstraint, + internal val cooldown: CooldownSpec?, + internal val arguments: List, + internal val requirements: List, + internal val children: List, + internal val handler: HandlerSpec?, +) { + internal val compiled: CompiledCommand by lazy(LazyThreadSafetyMode.NONE) { + CommandCompiler.compile(this) + } +} + +internal data class CommandRuntime( + val logger: Logger, + val config: DaisyConfig = DaisyConfig(), + val platform: DaisyPlatform = BukkitPlatform, +) + +internal data class CompiledRequirement( + val message: String?, + val check: CommandContext.() -> Boolean, +) + +internal data class CompiledCommand( + val name: String, + val description: String, + val aliases: List, + val root: CompiledNode, +) { + fun execute( + sender: CommandSender, + label: String, + args: List, + runtime: CommandRuntime, + ) { + if (!hasPermission(sender, root.permissions)) { + sender.sendFramework(runtime.config.messages.noPermission, runtime) + return + } + if (!satisfiesConstraint(sender, root.senderConstraint)) { + sender.sendFramework(renderConstraintMessage(root.senderConstraint, runtime), runtime) + return + } + + val resolution = resolveExecutionNode(root, args) + if (resolution.helpRequested) { + sendHelp(sender, resolution.node, runtime) + return + } + + if (resolution.unknownSubcommand != null) { + sender.sendFramework( + runtime.config.messages.unknownSubcommand + .replace("{input}", resolution.unknownSubcommand), + runtime, + ) + sendHelp(sender, resolution.node, runtime) + return + } + + val node = resolution.node + if (!hasPermission(sender, node.permissions)) { + sender.sendFramework(runtime.config.messages.noPermission, runtime) + return + } + if (!satisfiesConstraint(sender, node.senderConstraint)) { + sender.sendFramework(renderConstraintMessage(node.senderConstraint, runtime), runtime) + return + } + + if (node.handler == null && node.children.isNotEmpty() && resolution.remainingArgs.isEmpty()) { + sendHelp(sender, node, runtime) + return + } + + when (val parsed = parseArguments(node, resolution.remainingArgs, sender, runtime.platform)) { + is ArgumentParse.Failure -> renderArgumentFailure(sender, node, parsed.message, runtime) + is ArgumentParse.Success -> executeResolvedNode(sender, label, resolution, node, parsed.arguments, runtime) + } + } + + fun suggest( + sender: CommandSender, + args: List, + runtime: CommandRuntime, + ): List { + if (!hasPermission(sender, root.permissions) || !satisfiesConstraint(sender, root.senderConstraint)) { + return emptyList() + } + + if (args.isEmpty()) { + return distinctSuggestions( + childSuggestions(root, "", sender) + suggestArguments(root, emptyList(), sender, runtime.platform), + ) + } + + var node = root + var consumed = 0 + val currentIndex = args.lastIndex + while (consumed < currentIndex) { + val child = node.childrenByKey[args[consumed].normalized()] ?: break + if (!canView(sender, child)) { + return emptyList() + } + node = child + consumed++ + } + + val remaining = args.drop(consumed) + return distinctSuggestions( + childSuggestions(node, remaining.lastOrNull().orEmpty(), sender) + + suggestArguments(node, remaining, sender, runtime.platform), + ) + } +} + +internal data class CompiledNode( + val name: String, + val description: String, + val aliases: List, + val ownPermission: String?, + val permissions: List, + val senderConstraint: SenderConstraint, + val cooldown: CooldownSpec?, + val positionals: List, + val options: List, + val optionByLong: Map, + val optionByShort: Map, + val requirements: List, + val children: List, + val childrenByKey: Map, + val handler: HandlerSpec?, + val pathSegments: List, + val valueCount: Int, +) { + val pathString: String = pathSegments.joinToString(" ") + val cooldownKey: String = pathString +} + +private sealed interface ArgumentParse { + data class Success( + val arguments: ResolvedArguments, + ) : ArgumentParse + + data class Failure( + val message: String, + ) : ArgumentParse +} + +private data class ExecutionResolution( + val node: CompiledNode, + val remainingArgs: List, + val unknownSubcommand: String? = null, + val helpRequested: Boolean = false, +) + +private object CommandCompiler { + fun compile(spec: CommandSpec): CompiledCommand { + val rootSpec = + CommandNodeSpec( + name = spec.name, + description = spec.description, + aliases = spec.aliases, + permission = spec.permission, + senderConstraint = spec.senderConstraint, + cooldown = spec.cooldown, + arguments = spec.arguments, + requirements = spec.requirements, + children = spec.children, + handler = spec.handler, + ) + + val root = compileNode(rootSpec, emptyList(), SenderConstraint.ANY, emptyList(), emptyList()) + return CompiledCommand(spec.name, spec.description, spec.aliases, root) + } + + private fun compileNode( + spec: CommandNodeSpec, + parentPath: List, + parentConstraint: SenderConstraint, + parentPermissions: List, + parentRequirements: List, + ): CompiledNode { + validateNode(spec) + val effectiveConstraint = mergeConstraint(parentConstraint, spec.senderConstraint, spec.name) + + val permissions = ArrayList(parentPermissions.size + 1) + permissions += parentPermissions + spec.permission?.let(permissions::add) + + val requirements = ArrayList(parentRequirements.size + spec.requirements.size) + requirements += parentRequirements + for (requirement in spec.requirements) { + requirements += CompiledRequirement(requirement.message, requirement.check) + } + + val pathSegments = parentPath + spec.name + val children = ArrayList(spec.children.size) + val childrenByKey = LinkedHashMap(spec.children.size * 2) + for (childSpec in spec.children) { + val child = compileNode(childSpec, pathSegments, effectiveConstraint, permissions, requirements) + children += child + registerChildKey(childrenByKey, child.name, child, spec.name) + for (alias in child.aliases) { + registerChildKey(childrenByKey, alias, child, spec.name) + } + } + + val positionals = ArrayList() + val options = ArrayList() + val optionByLong = LinkedHashMap() + val optionByShort = LinkedHashMap() + for (argument in spec.arguments) { + when (argument.kind) { + ArgumentKind.POSITIONAL -> { + positionals += argument + } + + ArgumentKind.OPTION, + ArgumentKind.FLAG, + -> { + options += argument + val longName = requireNotNull(argument.longName) + require(optionByLong.putIfAbsent(longName.normalized(), argument) == null) { + "Duplicate option '--$longName' in '${spec.name}'." + } + argument.shortName?.let { shortName -> + require(optionByShort.putIfAbsent(shortName.normalized(), argument) == null) { + "Duplicate option '-$shortName' in '${spec.name}'." + } + } + } + } + } + + return CompiledNode( + name = spec.name, + description = spec.description, + aliases = spec.aliases, + ownPermission = spec.permission, + permissions = permissions, + senderConstraint = effectiveConstraint, + cooldown = spec.cooldown, + positionals = positionals, + options = options, + optionByLong = optionByLong, + optionByShort = optionByShort, + requirements = requirements, + children = children, + childrenByKey = childrenByKey, + handler = spec.handler, + pathSegments = pathSegments, + valueCount = spec.arguments.size, + ) + } + + private fun validateNode(spec: CommandNodeSpec) { + require(spec.name.matches(COMMAND_NAME_PATTERN)) { + "Invalid command node name '${spec.name}'. Use lowercase letters, numbers, dashes, or underscores." + } + if (spec.cooldown != null) { + require(!spec.cooldown.duration.isZero && !spec.cooldown.duration.isNegative) { + "Cooldown for '${spec.name}' must be greater than zero." + } + } + + val argumentNames = HashSet() + var encounteredOptionalPositional = false + var encounteredGreedy = false + for (argument in spec.arguments) { + check(argumentNames.add(argument.name.normalized())) { + "Duplicate argument name '${argument.name}' in '${spec.name}'." + } + + when (argument.kind) { + ArgumentKind.POSITIONAL -> { + if (encounteredGreedy) { + error("Greedy argument '${argument.name}' in '${spec.name}' must be the last positional argument.") + } + if (!argument.optional && !argument.hasDefault && encounteredOptionalPositional) { + error("Required argument '${argument.name}' in '${spec.name}' cannot follow an optional or defaulted argument.") + } + if (argument.optional || argument.hasDefault) { + encounteredOptionalPositional = true + } + if (argument.parser.greedy) { + encounteredGreedy = true + } + } + + ArgumentKind.OPTION -> { + require(!argument.parser.greedy) { + "Option '${argument.name}' in '${spec.name}' cannot use a greedy parser." + } + validateOptionNames(argument, spec.name) + } + + ArgumentKind.FLAG -> { + validateOptionNames(argument, spec.name) + } + } + } + + val childKeys = LinkedHashMap() + for (child in spec.children) { + require(child.name.normalized() !in HELP_ALIASES) { + "'${child.name}' is reserved by DaisyCommands for help." + } + registerAlias(childKeys, child.name, child.name, spec.name) + for (alias in child.aliases) { + require(alias.normalized() !in HELP_ALIASES) { + "'$alias' is reserved by DaisyCommands for help." + } + require(alias.matches(COMMAND_NAME_PATTERN)) { + "Invalid alias '$alias' in '${spec.name}'." + } + registerAlias(childKeys, alias, child.name, spec.name) + } + } + + require(spec.handler != null || spec.children.isNotEmpty()) { + "Command node '${spec.name}' must define a handler or at least one subcommand." + } + } + + private fun validateOptionNames( + argument: CompiledArgument, + nodeName: String, + ) { + val longName = requireNotNull(argument.longName) + require(longName.matches(COMMAND_NAME_PATTERN)) { + "Invalid option name '$longName' in '$nodeName'." + } + argument.shortName?.let { shortName -> + require(shortName.length == 1 && shortName[0].isLetterOrDigit()) { + "Invalid short option name '$shortName' in '$nodeName'." + } + } + } + + private fun mergeConstraint( + parent: SenderConstraint, + child: SenderConstraint, + name: String, + ): SenderConstraint = + when { + parent == SenderConstraint.ANY -> child + child == SenderConstraint.ANY -> parent + parent == child -> parent + else -> error("Command node '$name' has conflicting sender constraints.") + } + + private fun registerChildKey( + childrenByKey: MutableMap, + rawKey: String, + child: CompiledNode, + parentName: String, + ) { + val key = rawKey.normalized() + require(childrenByKey.putIfAbsent(key, child) == null) { + "Duplicate child key '$rawKey' in '$parentName'." + } + } + + private fun registerAlias( + registry: MutableMap, + rawKey: String, + owner: String, + parentName: String, + ) { + val key = rawKey.normalized() + require(registry.putIfAbsent(key, owner) == null) { + "Duplicate child key '$rawKey' in '$parentName'." + } + } +} + +private fun executeResolvedNode( + sender: CommandSender, + label: String, + resolution: ExecutionResolution, + node: CompiledNode, + arguments: ResolvedArguments, + runtime: CommandRuntime, +) { + val context = createContext(sender, label, node, resolution.remainingArgs, arguments, runtime) + for (requirement in node.requirements) { + val passes = + try { + requirement.check(context) + } catch (_: AbortExecution) { + return + } + if (!passes) { + context.sendFramework(requirement.message ?: runtime.config.messages.invalidState) + return + } + } + + val cooldown = node.cooldown + if (cooldown != null && sender is Player && !bypassesCooldown(sender, cooldown)) { + val remaining = DaisyCooldowns.remaining(sender, node.cooldownKey, cooldown.duration) + if (!remaining.isZero) { + sender.sendFramework(renderCooldownMessage(node, cooldown, remaining, runtime), runtime) + return + } + } + + try { + invokeHandler(node.handler, context) + if (cooldown != null && sender is Player && !bypassesCooldown(sender, cooldown) && context.wasSuccessful()) { + DaisyCooldowns.set(sender, node.cooldownKey) + } + } catch (_: AbortExecution) { + return + } catch (throwable: Throwable) { + sender.sendFramework(runtime.config.messages.exception, runtime) + runtime.logger.severe("Failed to execute /$label ${resolution.remainingArgs.joinToString(" ")}") + runtime.logger.severe(throwable.stackTraceToString()) + } +} + +private fun resolveExecutionNode( + root: CompiledNode, + args: List, +): ExecutionResolution { + var node = root + var index = 0 + + while (index < args.size) { + val token = args[index] + val normalized = token.normalized() + if (normalized in HELP_ALIASES && index == args.lastIndex) { + return ExecutionResolution(node = node, remainingArgs = emptyList(), helpRequested = true) + } + + val child = node.childrenByKey[normalized] ?: break + node = child + index++ + } + + val remaining = args.drop(index) + val unknownSubcommand = + if (remaining.isNotEmpty() && node.children.isNotEmpty() && node.positionals.isEmpty() && node.handler == null) { + remaining.first() + } else { + null + } + + return ExecutionResolution(node, remaining, unknownSubcommand, helpRequested = false) +} + +private fun parseArguments( + node: CompiledNode, + args: List, + sender: CommandSender, + platform: DaisyPlatform, +): ArgumentParse { + if (node.valueCount == 0) { + if (args.isNotEmpty()) { + return ArgumentParse.Failure("Too many arguments were provided.") + } + return ArgumentParse.Success(ResolvedArguments(emptyArray(), emptyMap())) + } + + val values = arrayOfNulls(node.valueCount) + val valuesByName = LinkedHashMap(node.valueCount) + val positionalTokens = ArrayList(args.size) + val seenOptions = HashSet(node.options.size) + var index = 0 + var optionParsing = true + + while (index < args.size) { + val token = args[index] + if (optionParsing && token == "--") { + optionParsing = false + index++ + continue + } + + if (optionParsing && token.startsWith("--") && token.length > 2) { + val rawName = token.substring(2) + val option = + node.optionByLong[rawName.normalized()] + ?: return ArgumentParse.Failure("Unknown option '--$rawName'.") + if (!seenOptions.add(option.name.normalized())) { + return ArgumentParse.Failure("Option '--${option.longName}' may only be provided once.") + } + if (option.kind == ArgumentKind.FLAG) { + values[option.slot] = true + valuesByName[option.name] = true + index++ + continue + } + + val rawValue = + args.getOrNull(index + 1) + ?: return ArgumentParse.Failure("Option '--${option.longName}' requires a value.") + if (rawValue == "--") { + return ArgumentParse.Failure("Option '--${option.longName}' requires a value.") + } + when (val parsed = parseValue(option, rawValue, sender, platform, node.pathSegments, valuesByName)) { + is ParseValueResult.Failure -> { + return ArgumentParse.Failure(parsed.message) + } + + is ParseValueResult.Success -> { + values[option.slot] = parsed.value + valuesByName[option.name] = parsed.value + } + } + index += 2 + continue + } + + if (optionParsing && token.startsWith("-") && token.length == 2) { + val rawName = token.substring(1) + val option = node.optionByShort[rawName.normalized()] + if (option != null) { + if (!seenOptions.add(option.name.normalized())) { + return ArgumentParse.Failure("Option '--${option.longName}' may only be provided once.") + } + if (option.kind == ArgumentKind.FLAG) { + values[option.slot] = true + valuesByName[option.name] = true + index++ + continue + } + + val rawValue = + args.getOrNull(index + 1) + ?: return ArgumentParse.Failure("Option '-${option.shortName}' requires a value.") + if (rawValue == "--") { + return ArgumentParse.Failure("Option '-${option.shortName}' requires a value.") + } + when (val parsed = parseValue(option, rawValue, sender, platform, node.pathSegments, valuesByName)) { + is ParseValueResult.Failure -> { + return ArgumentParse.Failure(parsed.message) + } + + is ParseValueResult.Success -> { + values[option.slot] = parsed.value + valuesByName[option.name] = parsed.value + } + } + index += 2 + continue + } + } + + positionalTokens += token + index++ + } + + for (option in node.options) { + if (valuesByName.containsKey(option.name)) { + continue + } + when { + option.kind == ArgumentKind.FLAG -> { + values[option.slot] = false + valuesByName[option.name] = false + } + + option.hasDefault -> { + values[option.slot] = option.defaultValue + valuesByName[option.name] = option.defaultValue + } + + option.optional -> { + valuesByName[option.name] = null + } + + else -> { + return ArgumentParse.Failure("Missing option '--${option.longName}'.") + } + } + } + + var positionalIndex = 0 + var tokenIndex = 0 + while (positionalIndex < node.positionals.size) { + val argument = node.positionals[positionalIndex] + val rawValue = + if (argument.parser.greedy) { + if (tokenIndex < positionalTokens.size) { + positionalTokens.drop(tokenIndex).joinToString(" ") + } else { + null + } + } else { + positionalTokens.getOrNull(tokenIndex) + } + + if (rawValue == null) { + when { + argument.hasDefault -> { + values[argument.slot] = argument.defaultValue + valuesByName[argument.name] = argument.defaultValue + } + + argument.optional -> { + valuesByName[argument.name] = null + } + + else -> { + return ArgumentParse.Failure("Missing argument <${argument.name}>.") + } + } + positionalIndex++ + continue + } + + when (val parsed = parseValue(argument, rawValue, sender, platform, node.pathSegments, valuesByName)) { + is ParseValueResult.Failure -> { + return ArgumentParse.Failure(parsed.message) + } + + is ParseValueResult.Success -> { + values[argument.slot] = parsed.value + valuesByName[argument.name] = parsed.value + } + } + + if (argument.parser.greedy) { + tokenIndex = positionalTokens.size + positionalIndex++ + break + } + + tokenIndex++ + positionalIndex++ + } + + if (tokenIndex < positionalTokens.size) { + return ArgumentParse.Failure("Too many arguments were provided.") + } + + for (argument in node.positionals) { + if (!valuesByName.containsKey(argument.name)) { + valuesByName[argument.name] = null + } + } + + return ArgumentParse.Success(ResolvedArguments(values, valuesByName)) +} + +private sealed interface ParseValueResult { + data class Success( + val value: Any?, + ) : ParseValueResult + + data class Failure( + val message: String, + ) : ParseValueResult +} + +private fun parseValue( + argument: CompiledArgument, + rawValue: String, + sender: CommandSender, + platform: DaisyPlatform, + pathSegments: List, + previousArguments: Map, +): ParseValueResult { + val parsed = + argument.parser.parse( + rawValue, + ParseContext( + sender = sender, + platform = platform, + commandPath = pathSegments, + argumentName = argument.name, + previousArguments = previousArguments, + ), + ) + + val value = + when (parsed) { + is ParseResult.Failure -> return ParseValueResult.Failure(parsed.error) + is ParseResult.Success -> parsed.value + } + + val validator = argument.validator + if (validator != null) { + val passes = + validator( + ValidationContext( + sender = sender, + platform = platform, + commandPath = pathSegments, + argumentName = argument.name, + value = value, + previousArguments = previousArguments, + ), + ) + if (!passes) { + return ParseValueResult.Failure(argument.validatorMessage ?: "Invalid value for <${argument.name}>.") + } + } + + return ParseValueResult.Success(value) +} + +private fun createContext( + sender: CommandSender, + label: String, + node: CompiledNode, + args: List, + arguments: ResolvedArguments, + runtime: CommandRuntime, +): CommandContext = + when (sender) { + is Player -> PlayerCommandContext(sender, label, node.pathSegments, args, arguments, runtime) + is ConsoleCommandSender -> ConsoleCommandContext(sender, label, node.pathSegments, args, arguments, runtime) + else -> CommandContext(sender, label, node.pathSegments, args, arguments, runtime) + } + +private fun invokeHandler( + handler: HandlerSpec?, + context: CommandContext, +) { + when (handler) { + null -> context.fail("Usage: /${context.path.joinToString(" ")}") + is AnyHandler -> handler.block(context) + is PlayerHandler -> handler.block(context as PlayerCommandContext) + is ConsoleHandler -> handler.block(context as ConsoleCommandContext) + } +} + +private fun suggestArguments( + node: CompiledNode, + nodeArgs: List, + sender: CommandSender, + platform: DaisyPlatform, +): List { + val currentInput = nodeArgs.lastOrNull().orEmpty() + val previousTokens = if (nodeArgs.isEmpty()) emptyList() else nodeArgs.dropLast(1) + val state = analyzeSuggestionState(node, previousTokens, sender, platform) ?: return emptyList() + + state.pendingOption?.let { + return suggestForArgument(it, currentInput, sender, platform, node.pathSegments, state.valuesByName) + } + + if (state.optionParsing && currentInput.startsWith("-")) { + return availableOptionSuggestions(node, currentInput, state.seenOptionNames) + } + + val nextArgument = node.positionals.getOrNull(state.positionalIndex) + val positionalSuggestions = + if (nextArgument != null) { + suggestForArgument(nextArgument, currentInput, sender, platform, node.pathSegments, state.valuesByName) + } else { + emptyList() + } + + if (!state.optionParsing || (currentInput.isNotEmpty() && !currentInput.startsWith("-"))) { + return positionalSuggestions + } + + return positionalSuggestions + availableOptionSuggestions(node, currentInput, state.seenOptionNames) +} + +private data class SuggestionState( + val valuesByName: LinkedHashMap, + val seenOptionNames: HashSet, + val positionalIndex: Int, + val optionParsing: Boolean, + val pendingOption: CompiledArgument?, +) + +private fun analyzeSuggestionState( + node: CompiledNode, + tokens: List, + sender: CommandSender, + platform: DaisyPlatform, +): SuggestionState? { + val valuesByName = LinkedHashMap() + val seenOptionNames = HashSet() + var positionalIndex = 0 + var optionParsing = true + var pendingOption: CompiledArgument? = null + var index = 0 + + while (index < tokens.size) { + val token = tokens[index] + if (pendingOption != null) { + when (val parsed = parseValue(pendingOption, token, sender, platform, node.pathSegments, valuesByName)) { + is ParseValueResult.Failure -> { + return null + } + + is ParseValueResult.Success -> { + valuesByName[pendingOption.name] = parsed.value + pendingOption = null + index++ + continue + } + } + } + + if (optionParsing && token == "--") { + optionParsing = false + index++ + continue + } + + if (optionParsing && token.startsWith("--") && token.length > 2) { + val option = node.optionByLong[token.substring(2).normalized()] ?: return null + if (!seenOptionNames.add(option.name.normalized())) { + return null + } + if (option.kind == ArgumentKind.FLAG) { + valuesByName[option.name] = true + } else { + pendingOption = option + } + index++ + continue + } + + if (optionParsing && token.startsWith("-") && token.length == 2) { + val option = node.optionByShort[token.substring(1).normalized()] + if (option != null) { + if (!seenOptionNames.add(option.name.normalized())) { + return null + } + if (option.kind == ArgumentKind.FLAG) { + valuesByName[option.name] = true + } else { + pendingOption = option + } + index++ + continue + } + } + + val positional = node.positionals.getOrNull(positionalIndex) ?: return null + if (positional.parser.greedy) { + val rawValue = tokens.drop(index).joinToString(" ") + when (val parsed = parseValue(positional, rawValue, sender, platform, node.pathSegments, valuesByName)) { + is ParseValueResult.Failure -> return null + is ParseValueResult.Success -> valuesByName[positional.name] = parsed.value + } + positionalIndex++ + index = tokens.size + continue + } + + when (val parsed = parseValue(positional, token, sender, platform, node.pathSegments, valuesByName)) { + is ParseValueResult.Failure -> return null + is ParseValueResult.Success -> valuesByName[positional.name] = parsed.value + } + positionalIndex++ + index++ + } + + return SuggestionState(valuesByName, seenOptionNames, positionalIndex, optionParsing, pendingOption) +} + +private fun suggestForArgument( + argument: CompiledArgument, + currentInput: String, + sender: CommandSender, + platform: DaisyPlatform, + pathSegments: List, + previousArguments: Map, +): List { + val context = + SuggestContext( + sender = sender, + platform = platform, + commandPath = pathSegments, + argumentName = argument.name, + currentInput = currentInput, + previousArguments = previousArguments, + ) + + val suggestions = argument.suggestions?.invoke(context)?.toList() ?: argument.parser.suggest(context) + return filterByPrefix(suggestions, currentInput) +} + +private fun availableOptionSuggestions( + node: CompiledNode, + currentInput: String, + seenOptionNames: Set, +): List { + val suggestions = ArrayList() + for (option in node.options) { + if (option.name.normalized() in seenOptionNames) { + continue + } + val longName = "--${option.longName}" + if (longName.startsWith(currentInput, ignoreCase = true)) { + suggestions += longName + } + option.shortName?.let { shortName -> + val shortForm = "-$shortName" + if (shortForm.startsWith(currentInput, ignoreCase = true)) { + suggestions += shortForm + } + } + } + return suggestions +} + +private fun childSuggestions( + node: CompiledNode, + currentInput: String, + sender: CommandSender, +): List { + if (node.children.isEmpty()) { + return emptyList() + } + + val suggestions = ArrayList() + for (child in node.children) { + if (!canView(sender, child)) { + continue + } + if (child.name.startsWith(currentInput, ignoreCase = true)) { + suggestions += child.name + } + for (alias in child.aliases) { + if (alias.startsWith(currentInput, ignoreCase = true)) { + suggestions += alias + } + } + } + return suggestions +} + +private fun sendHelp( + sender: CommandSender, + node: CompiledNode, + runtime: CommandRuntime, +) { + sender.sendRich(defaultHelpHeader(node, runtime), runtime) + sender.sendRich( + runtime.config.messages.usageLabel + .replace("{usage}", usageFor(node)), + runtime, + ) + + for (child in node.children) { + if (!canView(sender, child)) { + continue + } + val rendered = + runtime.config.messages.helpEntryRenderer?.render( + DaisyHelpEntryRenderContext( + parentPath = node.pathString, + childPath = child.pathString, + description = child.description, + ), + runtime.config.theme, + ) ?: defaultHelpEntry(child, runtime) + sender.sendRich(rendered, runtime) + } + + runtime.config.messages.helpFooter?.takeIf(String::isNotBlank)?.let { footer -> + sender.sendRich(footer, runtime) + } +} + +private fun usageFor(node: CompiledNode): String { + val builder = StringBuilder("/").append(node.pathString) + for (option in node.options) { + builder.append(" [--").append(option.longName) + if (option.kind == ArgumentKind.OPTION) { + builder.append(" <").append(option.name).append(">") + } + builder.append(']') + } + for (argument in node.positionals) { + val opening = if (argument.optional || argument.hasDefault) " [" else " <" + val closing = if (argument.optional || argument.hasDefault) "]" else ">" + builder.append(opening).append(argument.name) + if (argument.parser.greedy) { + builder.append("...") + } + builder.append(closing) + } + return builder.toString() +} + +private fun defaultHelpHeader( + node: CompiledNode, + runtime: CommandRuntime, +): String { + val theme = runtime.config.theme + val descriptionSuffix = + if (node.description.isBlank()) { + "" + } else { + " <${theme.descriptionColor}>- ${node.description}" + } + return "<${theme.commandColor}>/${node.pathString}$descriptionSuffix" +} + +private fun defaultHelpEntry( + child: CompiledNode, + runtime: CommandRuntime, +): String { + val theme = runtime.config.theme + val descriptionSuffix = + if (child.description.isBlank()) { + "" + } else { + " <${theme.descriptionColor}>- ${child.description}" + } + return "<${theme.accentColor}>/${child.pathString}$descriptionSuffix" +} + +private fun renderArgumentFailure( + sender: CommandSender, + node: CompiledNode, + message: String, + runtime: CommandRuntime, +) { + val rendered = + runtime.config.messages.argumentErrorRenderer?.render( + DaisyArgumentErrorRenderContext( + usage = usageFor(node), + message = message, + ), + runtime.config.theme, + ) ?: runtime.config.messages.invalidArgument + .replace("{message}", message) + sender.sendRich(rendered, runtime) + sender.sendRich( + runtime.config.messages.usageLabel + .replace("{usage}", usageFor(node)), + runtime, + ) +} + +private fun renderCooldownMessage( + node: CompiledNode, + cooldown: CooldownSpec, + remaining: Duration, + runtime: CommandRuntime, +): String { + cooldown.message?.let { template -> + return template.replace("{remaining}", DaisyCooldowns.format(remaining)) + } + + runtime.config.messages.cooldownRenderer?.let { renderer -> + return renderer.render( + DaisyCooldownRenderContext( + commandPath = node.pathString, + remaining = remaining, + formattedRemaining = DaisyCooldowns.format(remaining), + ), + runtime.config.theme, + ) + } + + return "<${runtime.config.theme.errorColor}>You must wait <${runtime.config.theme.accentColor}>${DaisyCooldowns.format( + remaining, + )}<${runtime.config.theme.errorColor}> before using this command again." +} + +private fun renderConstraintMessage( + constraint: SenderConstraint, + runtime: CommandRuntime, +): String = + when (constraint) { + SenderConstraint.ANY -> runtime.config.messages.invalidState + SenderConstraint.PLAYER_ONLY -> runtime.config.messages.playerOnly + SenderConstraint.CONSOLE_ONLY -> runtime.config.messages.consoleOnly + } + +private fun canView( + sender: CommandSender, + node: CompiledNode, +): Boolean = hasPermission(sender, node.permissions) && satisfiesConstraint(sender, node.senderConstraint) + +private fun hasPermission( + sender: CommandSender, + permissions: List, +): Boolean { + for (permission in permissions) { + if (!sender.hasPermission(permission)) { + return false + } + } + return true +} + +private fun satisfiesConstraint( + sender: CommandSender, + constraint: SenderConstraint, +): Boolean = + when (constraint) { + SenderConstraint.ANY -> true + SenderConstraint.PLAYER_ONLY -> sender is Player + SenderConstraint.CONSOLE_ONLY -> sender is ConsoleCommandSender + } + +private fun bypassesCooldown( + sender: Player, + cooldown: CooldownSpec, +): Boolean = cooldown.bypassPermission?.let(sender::hasPermission) == true + +private fun distinctSuggestions(values: List): List { + if (values.isEmpty()) { + return emptyList() + } + + val unique = LinkedHashMap(values.size) + for (value in values) { + unique.putIfAbsent(value.lowercase(), value) + } + return unique.values.toList() +} + +private fun filterByPrefix( + values: List, + currentInput: String, +): List { + if (currentInput.isEmpty()) { + return distinctSuggestions(values) + } + + val filtered = ArrayList(values.size) + for (value in values) { + if (value.startsWith(currentInput, ignoreCase = true)) { + filtered += value + } + } + return distinctSuggestions(filtered) +} + +private fun CommandSender.sendFramework( + message: String, + runtime: CommandRuntime, +) { + sendRich(message, runtime) +} + +private fun CommandSender.sendRich( + message: String, + runtime: CommandRuntime, +) { + sendMessage((runtime.config.messages.prefix + message).mm()) +} + +private fun String.normalized(): String = lowercase() diff --git a/src/main/kotlin/cat/daisy/command/core/DaisyCommands.kt b/src/main/kotlin/cat/daisy/command/core/DaisyCommands.kt index e2a3f50..f3bf715 100644 --- a/src/main/kotlin/cat/daisy/command/core/DaisyCommands.kt +++ b/src/main/kotlin/cat/daisy/command/core/DaisyCommands.kt @@ -1,332 +1,87 @@ -@file:Suppress("unused") +@file:Suppress("unused") package cat.daisy.command.core -import cat.daisy.command.arguments.ArgumentDef -import cat.daisy.command.context.CommandContext -import cat.daisy.command.context.TabContext -import cat.daisy.command.cooldown.DaisyCooldowns -import cat.daisy.command.text.DaisyText.mm -import org.bukkit.command.Command -import org.bukkit.command.CommandMap +import cat.daisy.command.dsl.CommandSetBuilder +import io.papermc.paper.command.brigadier.BasicCommand +import io.papermc.paper.command.brigadier.CommandSourceStack import org.bukkit.command.CommandSender -import org.bukkit.entity.Player import org.bukkit.plugin.java.JavaPlugin -import java.util.concurrent.ConcurrentHashMap -/** - * DaisyCommand Core Framework - * - * High-performance command registration with: - * - Zero-reflection execution (after initial setup) - * - Thread-safe command storage - * - Dynamic registration (no plugin.yml needed) - * - Nested subcommands support - * - Automatic help generation - */ -object DaisyCommands { - private val commands = ConcurrentHashMap() - private var commandMap: CommandMap? = null - private var pluginInstance: JavaPlugin? = null - private var pluginName: String = "daisycommand" - - // ═══════════════════════════════════════════════════════════════════════════════ - // INITIALIZATION - // ═══════════════════════════════════════════════════════════════════════════════ - - /** - * Initialize the framework with your plugin instance. - * Call this in your plugin's onEnable(). - */ - fun initialize(plugin: JavaPlugin) { - pluginInstance = plugin - pluginName = plugin.name.lowercase() - commandMap = fetchCommandMap(plugin) - } - - private fun fetchCommandMap(plugin: JavaPlugin): CommandMap = - try { - val field = plugin.server.javaClass.getDeclaredField("commandMap") - field.isAccessible = true - field.get(plugin.server) as? CommandMap - ?: throw IllegalStateException("CommandMap field returned null") - } catch (e: NoSuchFieldException) { - throw IllegalStateException("CommandMap field not found - incompatible server version", e) - } catch (e: IllegalAccessException) { - throw IllegalStateException("Cannot access CommandMap field", e) - } catch (e: ClassCastException) { - throw IllegalStateException("CommandMap field is not of expected type", e) - } - - // ═══════════════════════════════════════════════════════════════════════════════ - // REGISTRATION - // ═══════════════════════════════════════════════════════════════════════════════ +fun JavaPlugin.registerCommands(vararg commands: CommandSpec) { + registerCompiled(commands.toList(), DaisyConfig()) +} - /** - * Register a command with the framework. - */ - fun register(command: DaisyCommand) { - val map = - commandMap - ?: throw IllegalStateException("DaisyCommands not initialized! Call DaisyCommands.initialize(plugin) first.") - val wrapper = BukkitCommandWrapper(command) - map.register(pluginName, wrapper) - commands[command.name.lowercase()] = command - command.aliases.forEach { alias -> - commands[alias.lowercase()] = command - } - } +fun JavaPlugin.registerCommands(block: CommandSetBuilder.() -> Unit) { + val built = CommandSetBuilder().apply(block).build() + registerCompiled(built.commands, built.config) +} - /** - * Unregister a specific command. - */ - fun unregister(name: String) { - val map = commandMap ?: return - val lowerName = name.lowercase() - runCatching { - val knownCommands = map.knownCommands - knownCommands.remove(lowerName) - knownCommands.remove("$pluginName:$lowerName") - } - commands.remove(lowerName) +private fun JavaPlugin.registerCompiled( + commands: List, + config: DaisyConfig, +) { + validateRootKeys(commands) + val runtime = CommandRuntime(logger = logger, config = config) + for (command in commands) { + val compiled = command.compiled + registerCommand(command.name, command.description, command.aliases, PaperCommandAdapter(compiled, runtime)) } +} - /** - * Unregister all commands from this framework. - */ - fun unregisterAll() { - val map = commandMap ?: return - commands.keys.forEach { name -> - runCatching { - val knownCommands = map.knownCommands - knownCommands.remove(name) - knownCommands.remove("$pluginName:$name") - } +private fun validateRootKeys(commands: List) { + val keys = LinkedHashMap() + for (command in commands) { + registerRootKey(keys, command.name, command.name) + for (alias in command.aliases) { + registerRootKey(keys, alias, command.name) } - commands.clear() - } - - /** - * Get a registered command by name. - */ - operator fun get(name: String): DaisyCommand? = commands[name.lowercase()] - - /** - * Check if a command is registered. - */ - fun isRegistered(name: String): Boolean = commands.containsKey(name.lowercase()) - - /** - * Get all registered commands. - */ - fun getAll(): Collection = commands.values.distinctBy { it.name } - - /** - * Shutdown the framework - call in onDisable(). - */ - fun shutdown() { - unregisterAll() - DaisyCooldowns.clearAll() - commandMap = null - pluginInstance = null } } -// ═══════════════════════════════════════════════════════════════════════════════ -// BUKKIT WRAPPER -// ═══════════════════════════════════════════════════════════════════════════════ - -/** - * Bridges DaisyCommand to Bukkit's command system. - */ -private class BukkitCommandWrapper( - private val command: DaisyCommand, -) : Command(command.name) { - init { - description = command.description - usageMessage = command.usage - aliases = command.aliases.toList() - permission = command.permission +private fun registerRootKey( + keys: MutableMap, + rawKey: String, + owner: String, +) { + val normalized = rawKey.lowercase() + require(keys.putIfAbsent(normalized, owner) == null) { + "Duplicate root command key '$rawKey'." } - - override fun execute( - sender: CommandSender, - label: String, - args: Array, - ): Boolean = command.execute(sender, args.toList(), label) - - override fun tabComplete( - sender: CommandSender, - alias: String, - args: Array, - ): List = command.tabComplete(sender, args.toList()) } -// ═══════════════════════════════════════════════════════════════════════════════ -// DAISY COMMAND -// ═══════════════════════════════════════════════════════════════════════════════ - -/** - * Core command class with subcommand support. - */ -class DaisyCommand( - val name: String, - val description: String = "", - val usage: String = "/$name", - val permission: String? = null, - val aliases: Array = emptyArray(), - val playerOnly: Boolean = false, - val cooldown: Int = 0, - val cooldownMessage: String? = null, - val cooldownBypassPermission: String? = null, - val arguments: List = emptyList(), -) { - private val subcommands = ConcurrentHashMap() - private var executor: (CommandContext.() -> Unit)? = null - private var tabProvider: (TabContext.() -> List)? = null - - // ───────────────────────────────────────────────────────────────────────── - // CONFIGURATION - // ───────────────────────────────────────────────────────────────────────── - - fun addSubcommand( - name: String, - subcommand: SubCommand, +internal class PaperCommandAdapter( + private val command: CompiledCommand, + private val runtime: CommandRuntime, +) : BasicCommand { + override fun execute( + commandSourceStack: CommandSourceStack, + args: Array, ) { - subcommands[name.lowercase()] = subcommand - subcommand.aliases.forEach { alias -> - subcommands[alias.lowercase()] = subcommand - } - } - - fun onExecute(block: CommandContext.() -> Unit): DaisyCommand { - executor = block - return this - } - - fun tabComplete(block: TabContext.() -> List): DaisyCommand { - tabProvider = block - return this + command.execute(commandSourceStack.sender, command.name, args.toList(), runtime) } - fun getSubcommands(): Map = subcommands.toMap() - - // ───────────────────────────────────────────────────────────────────────── - // EXECUTION - // ───────────────────────────────────────────────────────────────────────── - - fun execute( - sender: CommandSender, - args: List, - label: String = name, - ): Boolean { - // Player-only check - if (playerOnly && sender !is Player) { - sender.sendMessage("<#e74c3c>✖ This command can only be used by players!".mm()) - return true - } - - // Permission check - if (permission != null && !sender.hasPermission(permission)) { - sender.sendMessage("<#e74c3c>✖ You don't have permission to use this command!".mm()) - return true - } + override fun suggest( + commandSourceStack: CommandSourceStack, + args: Array, + ): Collection = command.suggest(commandSourceStack.sender, args.toList(), runtime) - // Cooldown check - if (cooldown > 0 && sender is Player) { - val remaining = DaisyCooldowns.getRemainingCooldown(sender, name, cooldown) - if (remaining > 0 && (cooldownBypassPermission == null || !sender.hasPermission(cooldownBypassPermission))) { - val msg = - cooldownMessage - ?: "<#e74c3c>✖ Please wait $remaining seconds before using this again." - sender.sendMessage(msg.mm()) - return true + override fun canUse(sender: CommandSender): Boolean = + when (command.root.senderConstraint) { + SenderConstraint.ANY -> { + command.root.ownPermission == null || sender.hasPermission(command.root.ownPermission) } - } - // Try subcommand first - if (args.isNotEmpty()) { - val subName = args[0].lowercase() - subcommands[subName]?.let { sub -> - return sub.execute(sender, args.drop(1), label) + SenderConstraint.PLAYER_ONLY -> { + sender is org.bukkit.entity.Player && + (command.root.ownPermission == null || sender.hasPermission(command.root.ownPermission)) } - } - - // Parse named arguments - val namedArgs = parseArguments(args, arguments) - - // Execute main handler or show help - executor?.let { - CommandContext(sender, args, namedArgs, label).it() - return true - } - - // No handler, show subcommands if available - if (subcommands.isNotEmpty()) { - sendHelp(sender) - } - return true - } - fun tabComplete( - sender: CommandSender, - args: List, - ): List { - if (permission != null && !sender.hasPermission(permission)) { - return emptyList() - } - - return when { - args.size == 1 -> { - val prefix = args[0].lowercase() - val subSuggestions = - subcommands.entries - .asSequence() - .filter { (name, sub) -> name.startsWith(prefix) && sub.hasPermission(sender) } - .map { it.key } - .distinct() - .toList() - - val argSuggestions = - if (arguments.isNotEmpty()) { - getArgumentCompletions(0, args[0], arguments, sender) - } else { - emptyList() - } - - val customSuggestions = tabProvider?.let { TabContext(sender, args).it() } ?: emptyList() - (subSuggestions + argSuggestions + customSuggestions).distinct() - } - - args.size > 1 -> { - val subName = args[0].lowercase() - subcommands[subName]?.tabComplete(sender, args.drop(1)) - ?: tabProvider?.let { TabContext(sender, args).it() } - ?: getArgumentCompletions(args.size - 1, args.last(), arguments, sender) - } - - else -> { - tabProvider?.let { TabContext(sender, args).it() } ?: emptyList() + SenderConstraint.CONSOLE_ONLY -> { + sender is org.bukkit.command.ConsoleCommandSender && + (command.root.ownPermission == null || sender.hasPermission(command.root.ownPermission)) } } - } - - private fun sendHelp(sender: CommandSender) { - sender.sendMessage("<#3498db>━━━━━━━━━━ $name <#3498db>━━━━━━━━━━".mm()) - val visibleSubs = - subcommands.entries - .filter { it.value.hasPermission(sender) } - .distinctBy { it.value } - - if (visibleSubs.isEmpty()) { - sender.sendMessage("No available commands.".mm()) - } else { - visibleSubs.forEach { (subName, sub) -> - sender.sendMessage("<#f1c40f>/$name $subName - ${sub.description}".mm()) - } - } - - sender.sendMessage("<#3498db>━━━━━━━━━━━━━━━━━━━━━━━━━━━━━".mm()) - } + override fun permission(): String? = command.root.ownPermission } diff --git a/src/main/kotlin/cat/daisy/command/core/DaisyConfig.kt b/src/main/kotlin/cat/daisy/command/core/DaisyConfig.kt new file mode 100644 index 0000000..ef78f60 --- /dev/null +++ b/src/main/kotlin/cat/daisy/command/core/DaisyConfig.kt @@ -0,0 +1,88 @@ +@file:Suppress("unused") + +package cat.daisy.command.core + +import java.time.Duration + +class DaisyTheme { + var commandColor: String = "gold" + var descriptionColor: String = "gray" + var accentColor: String = "white" + var usageColor: String = "gray" + var errorColor: String = "red" + var warningColor: String = "yellow" +} + +data class DaisyHelpEntryRenderContext( + val parentPath: String, + val childPath: String, + val description: String, +) + +data class DaisyArgumentErrorRenderContext( + val usage: String, + val message: String, +) + +data class DaisyCooldownRenderContext( + val commandPath: String, + val remaining: Duration, + val formattedRemaining: String, +) + +fun interface DaisyHelpEntryRenderer { + fun render( + context: DaisyHelpEntryRenderContext, + theme: DaisyTheme, + ): String +} + +fun interface DaisyArgumentErrorRenderer { + fun render( + context: DaisyArgumentErrorRenderContext, + theme: DaisyTheme, + ): String +} + +fun interface DaisyCooldownRenderer { + fun render( + context: DaisyCooldownRenderContext, + theme: DaisyTheme, + ): String +} + +class DaisyMessages { + var prefix: String = "[Daisy] " + var noPermission: String = "You do not have permission to use this command." + var playerOnly: String = "This command can only be used by a player." + var consoleOnly: String = "This command can only be used from the console." + var invalidState: String = "You cannot use this command right now." + var unknownSubcommand: String = "Unknown subcommand {input}." + var invalidArgument: String = "{message}" + var exception: String = "An internal error occurred while executing this command." + var usageLabel: String = "Usage: {usage}" + var helpFooter: String? = null + var helpEntryRenderer: DaisyHelpEntryRenderer? = null + var argumentErrorRenderer: DaisyArgumentErrorRenderer? = null + var cooldownRenderer: DaisyCooldownRenderer? = null +} + +data class DaisyConfig( + val messages: DaisyMessages = DaisyMessages(), + val theme: DaisyTheme = DaisyTheme(), +) + +class DaisyConfigBuilder { + private val messages = DaisyMessages() + private val theme = DaisyTheme() + + fun messages(block: DaisyMessages.() -> Unit) { + messages.apply(block) + } + + fun theme(block: DaisyTheme.() -> Unit) { + theme.apply(block) + } + + internal fun build(): DaisyConfig = DaisyConfig(messages = messages, theme = theme) +} diff --git a/src/main/kotlin/cat/daisy/command/core/SubCommand.kt b/src/main/kotlin/cat/daisy/command/core/SubCommand.kt deleted file mode 100644 index b93ecef..0000000 --- a/src/main/kotlin/cat/daisy/command/core/SubCommand.kt +++ /dev/null @@ -1,431 +0,0 @@ -@file:Suppress("unused") - -package cat.daisy.command.core - -import cat.daisy.command.arguments.ArgumentDef -import cat.daisy.command.context.CommandContext -import cat.daisy.command.context.TabContext -import cat.daisy.command.cooldown.DaisyCooldowns -import cat.daisy.command.text.DaisyText.mm -import org.bukkit.Bukkit -import org.bukkit.GameMode -import org.bukkit.Material -import org.bukkit.command.CommandSender -import org.bukkit.entity.Player -import java.util.concurrent.ConcurrentHashMap - -/** - * SubCommand with independent permission and execution. - * Supports infinite nesting of subcommands. - */ -class SubCommand( - val name: String, - val description: String = "", - val permission: String? = null, - val playerOnly: Boolean = false, - val cooldown: Int = 0, - val cooldownMessage: String? = null, - val cooldownBypassPermission: String? = null, - val aliases: List = emptyList(), - val arguments: List = emptyList(), - nestedSubcommands: List = emptyList(), - private val executor: CommandContext.() -> Unit, - private val tabProvider: (TabContext.() -> List)? = null, -) { - private val subcommands = ConcurrentHashMap() - - init { - nestedSubcommands.forEach { data -> - val sub = - SubCommand( - data.name, - data.description, - data.permission, - data.playerOnly, - data.cooldown, - data.cooldownMessage, - data.cooldownBypassPermission, - data.aliases, - data.arguments, - data.subcommands, - data.executor, - data.tabProvider, - ) - subcommands[data.name.lowercase()] = sub - data.aliases.forEach { alias -> subcommands[alias.lowercase()] = sub } - } - } - - fun execute( - sender: CommandSender, - args: List, - label: String = "", - ): Boolean { - if (playerOnly && sender !is Player) { - sender.sendMessage("<#e74c3c>✖ This command can only be used by players!".mm()) - return true - } - - if (!hasPermission(sender)) { - sender.sendMessage("<#e74c3c>✖ You don't have permission to use this command!".mm()) - return true - } - - if (cooldown > 0 && sender is Player) { - val remaining = DaisyCooldowns.getRemainingCooldown(sender, name, cooldown) - if (remaining > 0 && (cooldownBypassPermission == null || !sender.hasPermission(cooldownBypassPermission))) { - val msg = cooldownMessage ?: "<#e74c3c>✖ Please wait $remaining seconds." - sender.sendMessage(msg.mm()) - return true - } - } - - // Check for nested subcommands - if (args.isNotEmpty() && subcommands.isNotEmpty()) { - val subName = args[0].lowercase() - subcommands[subName]?.let { sub -> - return sub.execute(sender, args.drop(1), label) - } - } - - val namedArgs = parseArguments(args, arguments) - CommandContext(sender, args, namedArgs, label).executor() - return true - } - - fun tabComplete( - sender: CommandSender, - args: List, - ): List { - if (!hasPermission(sender)) return emptyList() - - if (subcommands.isNotEmpty()) { - if (args.size == 1) { - val prefix = args[0].lowercase() - val subSuggestions = - subcommands.entries - .asSequence() - .filter { (name, sub) -> name.startsWith(prefix) && sub.hasPermission(sender) } - .map { it.key } - .distinct() - .toList() - if (subSuggestions.isNotEmpty()) return subSuggestions - } else if (args.size > 1) { - val subName = args[0].lowercase() - subcommands[subName]?.let { sub -> - return sub.tabComplete(sender, args.drop(1)) - } - } - } - - val argCompletions = getArgumentCompletions(args.size - 1, args.lastOrNull() ?: "", arguments, sender) - return tabProvider?.let { TabContext(sender, args).it() + argCompletions } ?: argCompletions - } - - fun hasPermission(sender: CommandSender): Boolean = permission == null || sender.hasPermission(permission) -} - -/** - * Data class for subcommand construction. - */ -data class SubCommandData( - val name: String, - val description: String, - val permission: String?, - val playerOnly: Boolean, - val cooldown: Int, - val cooldownMessage: String?, - val cooldownBypassPermission: String?, - val aliases: List, - val arguments: List, - val subcommands: List, - val executor: CommandContext.() -> Unit, - val tabProvider: (TabContext.() -> List)?, -) - -// ═══════════════════════════════════════════════════════════════════════════════ -// ARGUMENT PARSING UTILITIES -// ═══════════════════════════════════════════════════════════════════════════════ - -/** Maximum allowed input length for security */ -private const val MAX_ARG_LENGTH = 256 -private const val MAX_GREEDY_LENGTH = 1024 - -/** - * Parse command arguments into named values with input validation. - */ -internal fun parseArguments( - args: List, - definitions: List, -): Map { - val result = mutableMapOf() - var argIndex = 0 - - for (def in definitions) { - when (def) { - is ArgumentDef.StringArg -> { - args.getOrNull(argIndex)?.let { value -> - if (value.length <= MAX_ARG_LENGTH) { - result[def.name] = value - } - } - argIndex++ - } - - is ArgumentDef.GreedyStringArg -> { - if (argIndex < args.size) { - val joined = args.drop(argIndex).joinToString(" ") - if (joined.length <= MAX_GREEDY_LENGTH) { - result[def.name] = joined - } - } - break - } - - is ArgumentDef.IntArg -> { - args.getOrNull(argIndex)?.toIntOrNull()?.let { value -> - if (value in def.min..def.max) result[def.name] = value - } - argIndex++ - } - - is ArgumentDef.LongArg -> { - args.getOrNull(argIndex)?.toLongOrNull()?.let { value -> - if (value in def.min..def.max) result[def.name] = value - } - argIndex++ - } - - is ArgumentDef.DoubleArg -> { - args.getOrNull(argIndex)?.toDoubleOrNull()?.let { value -> - if (value in def.min..def.max) result[def.name] = value - } - argIndex++ - } - - is ArgumentDef.FloatArg -> { - args.getOrNull(argIndex)?.toFloatOrNull()?.let { value -> - if (value in def.min..def.max) result[def.name] = value - } - argIndex++ - } - - is ArgumentDef.BooleanArg -> { - args.getOrNull(argIndex)?.lowercase()?.let { value -> - when (value) { - "true", "yes", "on", "1" -> result[def.name] = true - "false", "no", "off", "0" -> result[def.name] = false - } - } - argIndex++ - } - - is ArgumentDef.PlayerArg -> { - args.getOrNull(argIndex)?.let { name -> - Bukkit.getPlayer(name)?.let { result[def.name] = it } - } - argIndex++ - } - - is ArgumentDef.OfflinePlayerArg -> { - args.getOrNull(argIndex)?.let { name -> - @Suppress("DEPRECATION") - val player = Bukkit.getOfflinePlayer(name) - if (player.hasPlayedBefore() || player.isOnline) { - result[def.name] = player - } - } - argIndex++ - } - - is ArgumentDef.WorldArg -> { - args.getOrNull(argIndex)?.let { name -> - Bukkit.getWorld(name)?.let { result[def.name] = it } - } - argIndex++ - } - - is ArgumentDef.MaterialArg -> { - args.getOrNull(argIndex)?.let { name -> - Material.matchMaterial(name)?.let { result[def.name] = it } - } - argIndex++ - } - - is ArgumentDef.GameModeArg -> { - args.getOrNull(argIndex)?.let { name -> - GameMode.entries.find { it.name.equals(name, ignoreCase = true) }?.let { - result[def.name] = it - } - } - argIndex++ - } - - is ArgumentDef.EntityTypeArg -> { - args.getOrNull(argIndex)?.let { name -> - org.bukkit.entity.EntityType.entries.find { it.name.equals(name, ignoreCase = true) }?.let { - result[def.name] = it - } - } - argIndex++ - } - - is ArgumentDef.UUIDArg -> { - args.getOrNull(argIndex)?.let { value -> - try { - result[def.name] = java.util.UUID.fromString(value) - } catch (_: Exception) { - // Invalid UUID, skip - } - } - argIndex++ - } - - is ArgumentDef.DurationArg -> { - args.getOrNull(argIndex)?.let { value -> - parseDuration(value)?.let { result[def.name] = it } - } - argIndex++ - } - - is ArgumentDef.ChoiceArg -> { - args.getOrNull(argIndex)?.let { value -> - if (def.choices.any { it.equals(value, ignoreCase = true) }) { - result[def.name] = value - } - } - argIndex++ - } - - is ArgumentDef.EnumArg<*> -> { - args.getOrNull(argIndex)?.let { value -> - def.enumClass.enumConstants?.find { it.name.equals(value, ignoreCase = true) }?.let { - result[def.name] = it - } - } - argIndex++ - } - - is ArgumentDef.CustomArg<*> -> { - args.getOrNull(argIndex)?.let { value -> - def.parser - .parse(value) - .getOrNull() - ?.let { result[def.name] = it } - } - argIndex++ - } - } - } - return result -} - -private fun parseDuration(input: String): java.time.Duration? { - val pattern = Regex("""(?:(\d+)d)?(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?""") - val match = pattern.matchEntire(input.lowercase()) ?: return null - val days = match.groupValues[1].toLongOrNull() ?: 0 - val hours = match.groupValues[2].toLongOrNull() ?: 0 - val minutes = match.groupValues[3].toLongOrNull() ?: 0 - val seconds = match.groupValues[4].toLongOrNull() ?: 0 - if (days == 0L && hours == 0L && minutes == 0L && seconds == 0L) return null - return java.time.Duration - .ofDays(days) - .plusHours(hours) - .plusMinutes(minutes) - .plusSeconds(seconds) -} - -/** - * Get tab completions for argument position. - */ -internal fun getArgumentCompletions( - index: Int, - current: String, - definitions: List, - @Suppress("UNUSED_PARAMETER") sender: CommandSender, -): List { - val def = definitions.getOrNull(index) ?: return emptyList() - return when (def) { - is ArgumentDef.PlayerArg -> { - Bukkit - .getOnlinePlayers() - .asSequence() - .map { it.name } - .filter { it.startsWith(current, ignoreCase = true) } - .toList() - } - - is ArgumentDef.OfflinePlayerArg -> { - Bukkit - .getOnlinePlayers() - .asSequence() - .map { it.name } - .filter { it.startsWith(current, ignoreCase = true) } - .toList() - } - - is ArgumentDef.WorldArg -> { - Bukkit - .getWorlds() - .asSequence() - .map { it.name } - .filter { it.startsWith(current, ignoreCase = true) } - .toList() - } - - is ArgumentDef.MaterialArg -> { - val upper = current.uppercase() - Material.entries - .asSequence() - .filter { it.name.startsWith(upper) } - .take(30) - .map { it.name.lowercase() } - .toList() - } - - is ArgumentDef.GameModeArg -> { - GameMode.entries - .map { it.name.lowercase() } - .filter { it.startsWith(current.lowercase()) } - } - - is ArgumentDef.EntityTypeArg -> { - org.bukkit.entity.EntityType.entries - .filter { it.isSpawnable } - .map { it.name.lowercase() } - .filter { it.startsWith(current.lowercase()) } - .take(30) - } - - is ArgumentDef.BooleanArg -> { - listOf("true", "false").filter { it.startsWith(current, ignoreCase = true) } - } - - is ArgumentDef.IntArg, is ArgumentDef.LongArg -> { - if (current.isEmpty()) listOf("1", "5", "10", "50", "100") else emptyList() - } - - is ArgumentDef.ChoiceArg -> { - def.choices.filter { it.startsWith(current, ignoreCase = true) } - } - - is ArgumentDef.EnumArg<*> -> { - def.enumClass.enumConstants - ?.map { it.name.lowercase() } - ?.filter { it.startsWith(current.lowercase()) } - ?: emptyList() - } - - is ArgumentDef.DurationArg -> { - if (current.isEmpty()) listOf("1h", "30m", "1d", "12h", "7d") else emptyList() - } - - is ArgumentDef.CustomArg<*> -> { - def.parser.complete(current) - } - - else -> { - emptyList() - } - } -} diff --git a/src/main/kotlin/cat/daisy/command/dsl/CommandDSL.kt b/src/main/kotlin/cat/daisy/command/dsl/CommandDSL.kt index 32fb1e9..64d9a25 100644 --- a/src/main/kotlin/cat/daisy/command/dsl/CommandDSL.kt +++ b/src/main/kotlin/cat/daisy/command/dsl/CommandDSL.kt @@ -1,526 +1,419 @@ +@file:Suppress("unused", "UNCHECKED_CAST") + package cat.daisy.command.dsl -import cat.daisy.command.arguments.ArgParser -import cat.daisy.command.arguments.ArgumentDef +import cat.daisy.command.arguments.ArgumentKind +import cat.daisy.command.arguments.ArgumentRef +import cat.daisy.command.arguments.CompiledArgument +import cat.daisy.command.arguments.DaisyParser +import cat.daisy.command.arguments.MutableArgumentDefinition +import cat.daisy.command.arguments.Parsers import cat.daisy.command.context.CommandContext -import cat.daisy.command.context.PlayerContext -import cat.daisy.command.context.TabContext -import cat.daisy.command.core.DaisyCommand -import cat.daisy.command.core.DaisyCommands -import cat.daisy.command.core.SubCommand -import cat.daisy.command.core.SubCommandData - -// ═══════════════════════════════════════════════════════════════════════════════ -// DSL ENTRY POINTS -// ═══════════════════════════════════════════════════════════════════════════════ - -/** - * Create and register a command using the DSL. - */ -inline fun daisyCommand( - name: String, - block: DaisyCommandBuilder.() -> Unit, -): DaisyCommand = DaisyCommandBuilder(name).apply(block).build().also { DaisyCommands.register(it) } - -/** - * Build a command without registering it. - */ -inline fun buildCommand( +import cat.daisy.command.context.ConsoleCommandContext +import cat.daisy.command.context.PlayerCommandContext +import cat.daisy.command.core.AnyHandler +import cat.daisy.command.core.CommandNodeSpec +import cat.daisy.command.core.CommandSpec +import cat.daisy.command.core.ConsoleHandler +import cat.daisy.command.core.CooldownSpec +import cat.daisy.command.core.DaisyConfig +import cat.daisy.command.core.DaisyConfigBuilder +import cat.daisy.command.core.HandlerSpec +import cat.daisy.command.core.PlayerHandler +import cat.daisy.command.core.RequirementSpec +import cat.daisy.command.core.SenderConstraint +import java.time.Duration + +fun command( name: String, - block: DaisyCommandBuilder.() -> Unit, -): DaisyCommand = DaisyCommandBuilder(name).apply(block).build() - -// ═══════════════════════════════════════════════════════════════════════════════ -// COMMAND BUILDER -// ═══════════════════════════════════════════════════════════════════════════════ - -/** - * Builder for creating DaisyCommand instances. - * Supports both Kotlin DSL and Java fluent API. - */ -class DaisyCommandBuilder( - private val name: String, -) { - @JvmField var description: String = "" - - @JvmField var usage: String = "/$name" - - @JvmField var permission: String? = null - - @JvmField var aliases: Array = emptyArray() - - @JvmField var playerOnly: Boolean = false + block: CommandBuilder.() -> Unit, +): CommandSpec = CommandBuilder(name, root = true).apply(block).build() - @JvmField var cooldown: Int = 0 +class CommandSetBuilder { + private val commands = mutableListOf() + private val configBuilder = DaisyConfigBuilder() - @JvmField var cooldownMessage: String? = null - - @JvmField var cooldownBypassPermission: String? = null - - @PublishedApi internal val subcommands = mutableListOf() + fun command( + name: String, + block: CommandBuilder.() -> Unit, + ) { + commands += + cat.daisy.command.dsl + .command(name, block) + } - @PublishedApi internal val arguments = mutableListOf() + fun add(command: CommandSpec) { + commands += command + } - @PublishedApi internal var executor: (CommandContext.() -> Unit)? = null + fun config(block: DaisyConfigBuilder.() -> Unit) { + configBuilder.apply(block) + } - @PublishedApi internal var tabProvider: (TabContext.() -> List)? = null + internal fun build(): BuiltCommandSet = BuiltCommandSet(commands.toList(), configBuilder.build()) +} - // ───────────────────────────────────────────────────────────────────────── - // JAVA-FRIENDLY SETTERS - // ───────────────────────────────────────────────────────────────────────── +internal data class BuiltCommandSet( + val commands: List, + val config: DaisyConfig, +) - fun setDescription(value: String) = apply { description = value } +class CommandBuilder internal constructor( + private val name: String, + private val root: Boolean, +) { + private var description: String = "" + private var permission: String? = null + private var aliases: MutableList = mutableListOf() + private var senderConstraint: SenderConstraint = SenderConstraint.ANY + private var cooldown: CooldownSpec? = null + private var handler: HandlerSpec? = null + private val requirements = mutableListOf() + private val children = mutableListOf() + private val arguments = mutableListOf() + + fun description(text: String) { + description = text + } - fun setUsage(value: String) = apply { usage = value } + fun aliases(vararg values: String) { + aliases = values.toMutableList() + } - fun setPermission(value: String?) = apply { permission = value } + fun permission(nodePermission: String) { + permission = nodePermission + } - fun setAliases(vararg names: String) = apply { aliases = names.toList().toTypedArray() } + fun playerOnly() { + senderConstraint = SenderConstraint.PLAYER_ONLY + } - fun setPlayerOnly(value: Boolean) = apply { playerOnly = value } + fun consoleOnly() { + senderConstraint = SenderConstraint.CONSOLE_ONLY + } - fun setCooldown(seconds: Int) = apply { cooldown = seconds } + fun cooldown( + duration: Duration, + bypassPermission: String? = null, + message: String? = null, + ) { + cooldown = CooldownSpec(duration, bypassPermission, message) + } - fun setCooldownMessage(value: String?) = apply { cooldownMessage = value } + fun requires( + message: String? = null, + predicate: CommandContext.() -> Boolean, + ) { + requirements += RequirementSpec(message, predicate) + } - fun setCooldownBypassPermission(value: String?) = apply { cooldownBypassPermission = value } + fun sub( + name: String, + block: CommandBuilder.() -> Unit, + ) { + children += CommandBuilder(name, root = false).apply(block) + } - // ───────────────────────────────────────────────────────────────────────── - // KOTLIN DSL METHODS - // ───────────────────────────────────────────────────────────────────────── + fun execute(block: CommandContext.() -> Unit) { + setHandler(AnyHandler(block)) + } - fun withAliases(vararg names: String) { - aliases = names.toList().toTypedArray() + fun executePlayer(block: PlayerCommandContext.() -> Unit) { + playerOnly() + setHandler(PlayerHandler(block)) } - // ───────────────────────────────────────────────────────────────────────── - // ARGUMENT DEFINITIONS - // ───────────────────────────────────────────────────────────────────────── + fun executeConsole(block: ConsoleCommandContext.() -> Unit) { + consoleOnly() + setHandler(ConsoleHandler(block)) + } - fun stringArgument( + fun string( name: String, - optional: Boolean = false, - ) { - arguments += ArgumentDef.StringArg(name, optional) - } + block: ArgumentRef.() -> Unit = {}, + ): ArgumentRef = positional(name, Parsers.STRING, block) - fun greedyStringArgument( + fun text( name: String, - optional: Boolean = false, - ) { - arguments += ArgumentDef.GreedyStringArg(name, optional) - } + block: ArgumentRef.() -> Unit = {}, + ): ArgumentRef = positional(name, Parsers.TEXT, block) - fun intArgument( + fun int( name: String, min: Int = Int.MIN_VALUE, max: Int = Int.MAX_VALUE, - optional: Boolean = false, - ) { - arguments += ArgumentDef.IntArg(name, min, max, optional) - } + block: ArgumentRef.() -> Unit = {}, + ): ArgumentRef = positional(name, Parsers.int(min, max), block) - fun longArgument( + fun long( name: String, min: Long = Long.MIN_VALUE, max: Long = Long.MAX_VALUE, - optional: Boolean = false, - ) { - arguments += ArgumentDef.LongArg(name, min, max, optional) - } + block: ArgumentRef.() -> Unit = {}, + ): ArgumentRef = positional(name, Parsers.long(min, max), block) - fun doubleArgument( + fun double( name: String, - min: Double = Double.MIN_VALUE, - max: Double = Double.MAX_VALUE, - optional: Boolean = false, - ) { - arguments += ArgumentDef.DoubleArg(name, min, max, optional) - } + min: Double = Double.NEGATIVE_INFINITY, + max: Double = Double.POSITIVE_INFINITY, + block: ArgumentRef.() -> Unit = {}, + ): ArgumentRef = positional(name, Parsers.double(min, max), block) - fun floatArgument( + fun float( name: String, - min: Float = Float.MIN_VALUE, - max: Float = Float.MAX_VALUE, - optional: Boolean = false, - ) { - arguments += ArgumentDef.FloatArg(name, min, max, optional) - } + min: Float = Float.NEGATIVE_INFINITY, + max: Float = Float.POSITIVE_INFINITY, + block: ArgumentRef.() -> Unit = {}, + ): ArgumentRef = positional(name, Parsers.float(min, max), block) - fun booleanArgument( + fun boolean( name: String, - optional: Boolean = false, - ) { - arguments += ArgumentDef.BooleanArg(name, optional) - } + block: ArgumentRef.() -> Unit = {}, + ): ArgumentRef = positional(name, Parsers.BOOLEAN, block) - fun playerArgument( + fun player( name: String, - optional: Boolean = false, - ) { - arguments += ArgumentDef.PlayerArg(name, optional) - } + block: ArgumentRef.() -> Unit = {}, + ): ArgumentRef = positional(name, Parsers.PLAYER, block) - fun offlinePlayerArgument( + fun offlinePlayer( name: String, - optional: Boolean = false, - ) { - arguments += ArgumentDef.OfflinePlayerArg(name, optional) - } + block: ArgumentRef.() -> Unit = {}, + ): ArgumentRef = positional(name, Parsers.OFFLINE_PLAYER, block) - fun worldArgument( + fun world( name: String, - optional: Boolean = false, - ) { - arguments += ArgumentDef.WorldArg(name, optional) - } + block: ArgumentRef.() -> Unit = {}, + ): ArgumentRef = positional(name, Parsers.WORLD, block) - fun materialArgument( + fun material( name: String, - optional: Boolean = false, - ) { - arguments += ArgumentDef.MaterialArg(name, optional) - } + block: ArgumentRef.() -> Unit = {}, + ): ArgumentRef = positional(name, Parsers.MATERIAL, block) - fun gameModeArgument( + fun gameMode( name: String, - optional: Boolean = false, - ) { - arguments += ArgumentDef.GameModeArg(name, optional) - } + block: ArgumentRef.() -> Unit = {}, + ): ArgumentRef = positional(name, Parsers.GAME_MODE, block) - fun entityTypeArgument( + fun entityType( name: String, - optional: Boolean = false, - ) { - arguments += ArgumentDef.EntityTypeArg(name, optional) - } + block: ArgumentRef.() -> Unit = {}, + ): ArgumentRef = positional(name, Parsers.ENTITY_TYPE, block) - fun uuidArgument( + fun uuid( name: String, - optional: Boolean = false, - ) { - arguments += ArgumentDef.UUIDArg(name, optional) - } + block: ArgumentRef.() -> Unit = {}, + ): ArgumentRef = positional(name, Parsers.UUID_PARSER, block) - fun durationArgument( + fun duration( name: String, - optional: Boolean = false, - ) { - arguments += ArgumentDef.DurationArg(name, optional) - } + block: ArgumentRef.() -> Unit = {}, + ): ArgumentRef = positional(name, Parsers.DURATION, block) - fun choiceArgument( + fun choice( name: String, - vararg choices: String, - optional: Boolean = false, - ) { - arguments += ArgumentDef.ChoiceArg(name, choices.toList(), optional) - } + vararg options: String, + block: ArgumentRef.() -> Unit = {}, + ): ArgumentRef = positional(name, Parsers.choice(*options), block) - inline fun > enumArgument( + inline fun > enum( name: String, - optional: Boolean = false, - ) { - arguments += ArgumentDef.EnumArg(name, E::class.java, optional) - } + noinline block: ArgumentRef.() -> Unit = {}, + ): ArgumentRef = positionalPublished(name, Parsers.enum(), block) - fun customArgument( + fun argument( name: String, - parser: ArgParser, - optional: Boolean = false, - ) { - arguments += ArgumentDef.CustomArg(name, parser, optional) - } - - // ───────────────────────────────────────────────────────────────────────── - // SUBCOMMANDS - // ───────────────────────────────────────────────────────────────────────── - - inline fun subcommand( - subName: String, - block: SubCommandBuilder.() -> Unit, - ) { - subcommands += SubCommandBuilder(subName).apply(block).build() - } - - // ───────────────────────────────────────────────────────────────────────── - // EXECUTION - // ───────────────────────────────────────────────────────────────────────── - - fun onExecute(block: CommandContext.() -> Unit) { - executor = block - } - - fun playerExecutor(block: PlayerContext.() -> Unit) { - playerOnly = true - executor = { asPlayer()?.let { PlayerContext(it, args, namedArgs, label).block() } } - } + parser: DaisyParser, + block: ArgumentRef.() -> Unit = {}, + ): ArgumentRef = positional(name, parser, block) + + fun flag( + longName: String, + shortName: String? = null, + block: ArgumentRef.() -> Unit = {}, + ): ArgumentRef = optionInternal(longName, Parsers.BOOLEAN, shortName, ArgumentKind.FLAG, block) + + fun stringOption( + longName: String, + shortName: String? = null, + block: ArgumentRef.() -> Unit = {}, + ): ArgumentRef = optionInternal(longName, Parsers.STRING, shortName, ArgumentKind.OPTION, block) + + fun textOption( + longName: String, + shortName: String? = null, + block: ArgumentRef.() -> Unit = {}, + ): ArgumentRef = optionInternal(longName, Parsers.OPTION_TEXT, shortName, ArgumentKind.OPTION, block) + + fun intOption( + longName: String, + shortName: String? = null, + min: Int = Int.MIN_VALUE, + max: Int = Int.MAX_VALUE, + block: ArgumentRef.() -> Unit = {}, + ): ArgumentRef = optionInternal(longName, Parsers.int(min, max), shortName, ArgumentKind.OPTION, block) - fun tabComplete(block: TabContext.() -> List) { - tabProvider = block + fun longOption( + longName: String, + shortName: String? = null, + min: Long = Long.MIN_VALUE, + max: Long = Long.MAX_VALUE, + block: ArgumentRef.() -> Unit = {}, + ): ArgumentRef = optionInternal(longName, Parsers.long(min, max), shortName, ArgumentKind.OPTION, block) + + fun doubleOption( + longName: String, + shortName: String? = null, + min: Double = Double.NEGATIVE_INFINITY, + max: Double = Double.POSITIVE_INFINITY, + block: ArgumentRef.() -> Unit = {}, + ): ArgumentRef = optionInternal(longName, Parsers.double(min, max), shortName, ArgumentKind.OPTION, block) + + fun floatOption( + longName: String, + shortName: String? = null, + min: Float = Float.NEGATIVE_INFINITY, + max: Float = Float.POSITIVE_INFINITY, + block: ArgumentRef.() -> Unit = {}, + ): ArgumentRef = optionInternal(longName, Parsers.float(min, max), shortName, ArgumentKind.OPTION, block) + + fun booleanOption( + longName: String, + shortName: String? = null, + block: ArgumentRef.() -> Unit = {}, + ): ArgumentRef = optionInternal(longName, Parsers.BOOLEAN, shortName, ArgumentKind.OPTION, block) + + fun playerOption( + longName: String, + shortName: String? = null, + block: ArgumentRef.() -> Unit = {}, + ): ArgumentRef = optionInternal(longName, Parsers.PLAYER, shortName, ArgumentKind.OPTION, block) + + fun durationOption( + longName: String, + shortName: String? = null, + block: ArgumentRef.() -> Unit = {}, + ): ArgumentRef = optionInternal(longName, Parsers.DURATION, shortName, ArgumentKind.OPTION, block) + + fun option( + longName: String, + parser: DaisyParser, + shortName: String? = null, + block: ArgumentRef.() -> Unit = {}, + ): ArgumentRef = optionInternal(longName, parser, shortName, ArgumentKind.OPTION, block) + + internal fun build(): CommandSpec { + check(root) { "Only root builders can build a CommandSpec." } + return CommandSpec( + name = name, + description = description, + aliases = aliases.toList(), + permission = permission, + senderConstraint = senderConstraint, + cooldown = cooldown, + arguments = compileArguments(), + requirements = requirements.toList(), + children = children.map { it.buildNode() }, + handler = handler, + ) } - // ───────────────────────────────────────────────────────────────────────── - // BUILD - // ───────────────────────────────────────────────────────────────────────── - - @PublishedApi - internal fun build(): DaisyCommand { - val cmd = - DaisyCommand( - name, - description, - usage, - permission, - aliases, - playerOnly, - cooldown, - cooldownMessage, - cooldownBypassPermission, - arguments.toList(), - ) - - subcommands.forEach { data -> - cmd.addSubcommand( - data.name, - SubCommand( - data.name, - data.description, - data.permission, - data.playerOnly, - data.cooldown, - data.cooldownMessage, - data.cooldownBypassPermission, - data.aliases, - data.arguments, - data.subcommands, - data.executor, - data.tabProvider, - ), + private fun buildNode(): CommandNodeSpec = + CommandNodeSpec( + name = name, + description = description, + aliases = aliases.toList(), + permission = permission, + senderConstraint = senderConstraint, + cooldown = cooldown, + arguments = compileArguments(), + requirements = requirements.toList(), + children = children.map { it.buildNode() }, + handler = handler, + ) + + private fun compileArguments(): List = + arguments.map { + CompiledArgument( + slot = it.slot, + name = it.name, + parser = it.parser, + kind = it.kind, + longName = it.longName, + shortName = it.shortName, + optional = it.optional, + hasDefault = it.defaultValue !== cat.daisy.command.arguments.NoDefaultValue, + defaultValue = it.defaultValue, + description = it.description, + suggestions = it.suggestions, + validatorMessage = it.validatorMessage, + validator = it.validator, ) } - executor?.let { cmd.onExecute(it) } - tabProvider?.let { cmd.tabComplete(it) } - return cmd + private fun setHandler(value: HandlerSpec) { + check(handler == null) { "Node '$name' already has an execution handler." } + handler = value } -} -// ═══════════════════════════════════════════════════════════════════════════════ -// SUBCOMMAND BUILDER -// ═══════════════════════════════════════════════════════════════════════════════ + private fun positional( + name: String, + parser: DaisyParser, + block: ArgumentRef.() -> Unit, + ): ArgumentRef = + createArgument( + name = name, + parser = parser, + kind = ArgumentKind.POSITIONAL, + longName = null, + shortName = null, + block = block, + ) -/** - * Builder for creating SubCommand instances. - */ -class SubCommandBuilder @PublishedApi - internal constructor( - private val name: String, - ) { - var description: String = "" - var permission: String? = null - var playerOnly: Boolean = false - var cooldown: Int = 0 - var cooldownMessage: String? = null - var cooldownBypassPermission: String? = null - var aliases: List = emptyList() - - @PublishedApi internal val arguments = mutableListOf() - - @PublishedApi internal val subcommands = mutableListOf() - - @PublishedApi internal var executor: (CommandContext.() -> Unit) = {} - - @PublishedApi internal var tabProvider: (TabContext.() -> List)? = null - - // ───────────────────────────────────────────────────────────────────────── - // ARGUMENT DEFINITIONS - // ───────────────────────────────────────────────────────────────────────── - - fun stringArgument( - name: String, - optional: Boolean = false, - ) { - arguments += ArgumentDef.StringArg(name, optional) - } - - fun greedyStringArgument( - name: String, - optional: Boolean = false, - ) { - arguments += ArgumentDef.GreedyStringArg(name, optional) - } - - fun intArgument( - name: String, - min: Int = Int.MIN_VALUE, - max: Int = Int.MAX_VALUE, - optional: Boolean = false, - ) { - arguments += ArgumentDef.IntArg(name, min, max, optional) - } - - fun longArgument( - name: String, - min: Long = Long.MIN_VALUE, - max: Long = Long.MAX_VALUE, - optional: Boolean = false, - ) { - arguments += ArgumentDef.LongArg(name, min, max, optional) - } - - fun doubleArgument( - name: String, - min: Double = Double.MIN_VALUE, - max: Double = Double.MAX_VALUE, - optional: Boolean = false, - ) { - arguments += ArgumentDef.DoubleArg(name, min, max, optional) - } - - fun floatArgument( - name: String, - min: Float = Float.MIN_VALUE, - max: Float = Float.MAX_VALUE, - optional: Boolean = false, - ) { - arguments += ArgumentDef.FloatArg(name, min, max, optional) - } - - fun booleanArgument( - name: String, - optional: Boolean = false, - ) { - arguments += ArgumentDef.BooleanArg(name, optional) - } - - fun playerArgument( - name: String, - optional: Boolean = false, - ) { - arguments += ArgumentDef.PlayerArg(name, optional) - } - - fun offlinePlayerArgument( - name: String, - optional: Boolean = false, - ) { - arguments += ArgumentDef.OfflinePlayerArg(name, optional) - } - - fun worldArgument( - name: String, - optional: Boolean = false, - ) { - arguments += ArgumentDef.WorldArg(name, optional) - } - - fun materialArgument( - name: String, - optional: Boolean = false, - ) { - arguments += ArgumentDef.MaterialArg(name, optional) - } - - fun gameModeArgument( - name: String, - optional: Boolean = false, - ) { - arguments += ArgumentDef.GameModeArg(name, optional) - } - - fun entityTypeArgument( - name: String, - optional: Boolean = false, - ) { - arguments += ArgumentDef.EntityTypeArg(name, optional) - } - - fun uuidArgument( - name: String, - optional: Boolean = false, - ) { - arguments += ArgumentDef.UUIDArg(name, optional) - } - - fun durationArgument( - name: String, - optional: Boolean = false, - ) { - arguments += ArgumentDef.DurationArg(name, optional) - } - - fun choiceArgument( - name: String, - vararg choices: String, - optional: Boolean = false, - ) { - arguments += ArgumentDef.ChoiceArg(name, choices.toList(), optional) - } - - inline fun > enumArgument( - name: String, - optional: Boolean = false, - ) { - arguments += ArgumentDef.EnumArg(name, E::class.java, optional) - } - - fun customArgument( - name: String, - parser: ArgParser, - optional: Boolean = false, - ) { - arguments += ArgumentDef.CustomArg(name, parser, optional) - } - - // ───────────────────────────────────────────────────────────────────────── - // SUBCOMMANDS - // ───────────────────────────────────────────────────────────────────────── - - inline fun subcommand( - subName: String, - block: SubCommandBuilder.() -> Unit, - ) { - subcommands += SubCommandBuilder(subName).apply(block).build() - } - - // ───────────────────────────────────────────────────────────────────────── - // EXECUTION - // ───────────────────────────────────────────────────────────────────────── - - fun onExecute(block: CommandContext.() -> Unit) { - executor = block - } - - fun playerExecutor(block: PlayerContext.() -> Unit) { - playerOnly = true - executor = { asPlayer()?.let { PlayerContext(it, args, namedArgs, label).block() } } - } - - fun tabComplete(block: TabContext.() -> List) { - tabProvider = block - } - - // ───────────────────────────────────────────────────────────────────────── - // BUILD - // ───────────────────────────────────────────────────────────────────────── - - @PublishedApi - internal fun build() = - SubCommandData( - name, - description, - permission, - playerOnly, - cooldown, - cooldownMessage, - cooldownBypassPermission, - aliases, - arguments.toList(), - subcommands.toList(), - executor, - tabProvider, + internal fun positionalPublished( + name: String, + parser: DaisyParser, + block: ArgumentRef.() -> Unit, + ): ArgumentRef = positional(name, parser, block) + + private fun optionInternal( + longName: String, + parser: DaisyParser, + shortName: String?, + kind: ArgumentKind, + block: ArgumentRef.() -> Unit, + ): ArgumentRef = + createArgument( + name = longName, + parser = parser, + kind = kind, + longName = longName, + shortName = shortName, + block = block, + ) + + private fun createArgument( + name: String, + parser: DaisyParser, + kind: ArgumentKind, + longName: String?, + shortName: String?, + block: ArgumentRef.() -> Unit, + ): ArgumentRef { + val definition = + MutableArgumentDefinition( + slot = arguments.size, + name = name, + parser = parser as DaisyParser, + kind = kind, + longName = longName, + shortName = shortName, ) + val ref = ArgumentRef(definition, name) + arguments += definition + ref.apply(block) + return ref } +} diff --git a/src/main/kotlin/cat/daisy/command/text/DaisyText.kt b/src/main/kotlin/cat/daisy/command/text/DaisyText.kt index 75027da..d08e273 100644 --- a/src/main/kotlin/cat/daisy/command/text/DaisyText.kt +++ b/src/main/kotlin/cat/daisy/command/text/DaisyText.kt @@ -3,175 +3,12 @@ package cat.daisy.command.text import net.kyori.adventure.text.Component import net.kyori.adventure.text.format.TextDecoration import net.kyori.adventure.text.minimessage.MiniMessage -import org.bukkit.Bukkit -import java.time.LocalDateTime -import java.time.format.DateTimeFormatter -/** - * DaisyText - MiniMessage text utilities for DaisyCommand - * - * Features: - * - MiniMessage parsing with legacy color support - * - Gradient and rainbow text - * - Placeholder replacement - * - Console logging with colors - */ object DaisyText { private val miniMessage = MiniMessage.miniMessage() - private val logFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") - private val legacyColorRegex = """&([0-9a-fk-or])""".toRegex() - private val legacyColorMap = - mapOf( - "0" to "black", - "1" to "dark_blue", - "2" to "dark_green", - "3" to "dark_aqua", - "4" to "dark_red", - "5" to "dark_purple", - "6" to "gold", - "7" to "gray", - "8" to "dark_gray", - "9" to "blue", - "a" to "green", - "b" to "aqua", - "c" to "red", - "d" to "light_purple", - "e" to "yellow", - "f" to "white", - ) - private val legacyFormattingMap = - mapOf( - "k" to "obfuscated", - "l" to "bold", - "m" to "strikethrough", - "n" to "underlined", - "o" to "italic", - "r" to "reset", - ) - - /** - * Standard color palette for consistent styling. - */ - object Colors { - const val PRIMARY = "#3498db" - const val SECONDARY = "#2ecc71" - const val ERROR = "#e74c3c" - const val SUCCESS = "#2ecc71" - const val WARNING = "#f1c40f" - const val INFO = "#3498db" - const val BROADCAST = "#9b59b6" - const val SYSTEM = "#34495e" - const val ACCENT = "#e67e22" - const val MUTED = "#95a5a6" - } - - /** - * Parse string as MiniMessage with italic disabled by default. - */ fun String.mm(): Component = miniMessage - .deserialize(convertLegacyColors()) + .deserialize(this) .decorationIfAbsent(TextDecoration.ITALIC, TextDecoration.State.FALSE) - - /** - * Parse string as raw MiniMessage (keeps default decorations). - */ - fun String.mmRaw(): Component = miniMessage.deserialize(convertLegacyColors()) - - /** - * Convert legacy &-codes to MiniMessage format. - */ - fun String.convertLegacyColors(): String = - replace(legacyColorRegex) { match -> - val code = match.groupValues[1] - when { - legacyColorMap.containsKey(code) -> { - "<${legacyColorMap[code]}>" - } - - legacyFormattingMap.containsKey(code) -> { - if (code == "r") "" else "<${legacyFormattingMap[code]}>" - } - - else -> { - match.value - } - } - } - - /** - * Apply gradient colors to text. - */ - fun String.gradient(vararg colors: String): Component = "$this".mm() - - /** - * Apply rainbow gradient to text. - */ - fun String.rainbow(): Component = gradient("#FF0000", "#FF7F00", "#FFFF00", "#00FF00", "#0000FF", "#4B0082", "#9400D3") - - /** - * Strip all MiniMessage tags and legacy colors. - */ - fun String.stripColors(): String = MiniMessage.miniMessage().stripTags(convertLegacyColors()) - - /** - * Replace placeholders in format {key} with values. - */ - fun String.replacePlaceholders(vararg pairs: Pair): String { - var result = this - pairs.forEach { (key, value) -> result = result.replace("{$key}", value.toString()) } - return result - } - - /** - * Replace placeholders using a map. - */ - fun String.replacePlaceholders(placeholders: Map): String { - var result = this - placeholders.forEach { (key, value) -> result = result.replace("{$key}", value.toString()) } - return result - } - - /** - * Log a message to console with timestamp and color. - */ - fun log( - message: String, - level: String = "INFO", - throwable: Throwable? = null, - context: Map = emptyMap(), - ) { - val timestamp = LocalDateTime.now().format(logFormatter) - val (color, prefix) = - when (level.uppercase()) { - "ERROR" -> Colors.ERROR to "✖ " - "SUCCESS" -> Colors.SUCCESS to "✔ " - "WARNING" -> Colors.WARNING to "⚠ " - "DEBUG" -> Colors.MUTED to "• " - else -> Colors.INFO to "✦ " - } - val contextStr = - if (context.isNotEmpty()) { - " ${context.entries.joinToString(" | ") { "${it.key}: ${it.value}" }}" - } else { - "" - } - val fullMessage = "[$timestamp] [$level] $prefix $message$contextStr" - Bukkit.getConsoleSender().sendMessage("<$color>$fullMessage".mm()) - throwable?.printStackTrace() - } - - fun logInfo(message: String) = log(message, "INFO") - - fun logSuccess(message: String) = log(message, "SUCCESS") - - fun logWarning(message: String) = log(message, "WARNING") - - fun logError( - message: String, - throwable: Throwable? = null, - ) = log(message, "ERROR", throwable) - - fun logDebug(message: String) = log(message, "DEBUG") } diff --git a/src/test/kotlin/cat/daisy/command/core/DaisyCommandsTest.kt b/src/test/kotlin/cat/daisy/command/core/DaisyCommandsTest.kt new file mode 100644 index 0000000..f657503 --- /dev/null +++ b/src/test/kotlin/cat/daisy/command/core/DaisyCommandsTest.kt @@ -0,0 +1,449 @@ +package cat.daisy.command.core + +import cat.daisy.command.arguments.DaisyParser +import cat.daisy.command.arguments.DaisyPlatform +import cat.daisy.command.arguments.ParseContext +import cat.daisy.command.arguments.ParseResult +import cat.daisy.command.arguments.SuggestContext +import cat.daisy.command.cooldown.DaisyCooldowns +import cat.daisy.command.dsl.command +import net.kyori.adventure.text.Component +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer +import org.bukkit.OfflinePlayer +import org.bukkit.World +import org.bukkit.command.CommandSender +import org.bukkit.entity.Player +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.mockito.ArgumentMatchers.any +import org.mockito.Mockito.doAnswer +import org.mockito.Mockito.mock +import org.mockito.Mockito.`when` +import java.time.Duration +import java.util.UUID +import java.util.logging.Logger + +class DaisyCommandsTest { + private val plainSerializer = PlainTextComponentSerializer.plainText() + + @AfterEach + fun tearDown() { + DaisyCooldowns.clearAll() + messageStore.clear() + } + + @Test + fun `typed refs resolve positionals flags and options`() { + val alice = player("Alice") + val bob = player("Bob") + val runtime = runtime(players = listOf(alice, bob)) + + var invited = "" + var silentInvite = false + var expirySeconds = 0L + + val spec = + command("island") { + sub("invite") { + val target = player("target") + val silent = flag("silent", "s") + val expires = durationOption("expires", "e").default(Duration.ofMinutes(5)) + + executePlayer { + invited = target().name + silentInvite = silent() + expirySeconds = expires().seconds + } + } + } + + spec.compiled.execute(alice, "island", listOf("invite", "--expires", "30s", "-s", "Bob"), runtime) + + assertEquals("Bob", invited) + assertTrue(silentInvite) + assertEquals(30L, expirySeconds) + } + + @Test + fun `options can appear after positionals and sentinel stops option parsing`() { + val alice = player("Alice") + val runtime = runtime(players = listOf(alice)) + + var rawMessage = "" + var announce = false + + val spec = + command("mail") { + val target = string("target") + val message = text("message") + val broadcast = flag("broadcast", "b") + + executePlayer { + rawMessage = "${target()}:${message()}" + announce = broadcast() + } + } + + spec.compiled.execute(alice, "mail", listOf("Bob", "--", "--broadcast", "hello", "there", "-b"), runtime) + assertEquals("Bob:--broadcast hello there -b", rawMessage) + assertFalse(announce) + + spec.compiled.execute(alice, "mail", listOf("Bob", "hello", "--broadcast"), runtime) + assertTrue(announce) + } + + @Test + fun `requirements are inherited and stop execution with custom message`() { + val alice = player("Alice") + val runtime = runtime(players = listOf(alice)) + var executed = false + + val spec = + command("island") { + playerOnly() + requires("Own an island first.") { false } + + sub("home") { + executePlayer { + executed = true + } + } + } + + spec.compiled.execute(alice, "island", listOf("home"), runtime) + + assertFalse(executed) + assertTrue(sentMessages(alice).any { it.contains("Own an island first.") }) + } + + @Test + fun `help output hides children without permission and includes usage`() { + val sender = sender("Console") + val runtime = runtime() + + val spec = + command("team") { + description("Team management") + + sub("open") { + description("Open to everyone") + execute { } + } + + sub("secret") { + description("Hidden command") + permission("team.secret") + execute { } + } + } + + spec.compiled.execute(sender, "team", emptyList(), runtime) + + val messages = sentMessages(sender) + assertTrue(messages.any { it.contains("/team") }) + assertTrue(messages.any { it.contains("Usage: /team") }) + assertTrue(messages.any { it.contains("/team open") }) + assertFalse(messages.any { it.contains("/team secret") }) + } + + @Test + fun `cooldown only applies after successful execution`() { + val player = player("Alice") + val runtime = runtime(players = listOf(player)) + + var failedExecutions = 0 + val failing = + command("heal") { + cooldown(Duration.ofSeconds(30)) + executePlayer { + failedExecutions++ + fail("Nope") + } + } + + failing.compiled.execute(player, "heal", emptyList(), runtime) + failing.compiled.execute(player, "heal", emptyList(), runtime) + assertEquals(2, failedExecutions) + + var successfulExecutions = 0 + val successful = + command("warp") { + cooldown(Duration.ofSeconds(30)) + executePlayer { + successfulExecutions++ + } + } + + successful.compiled.execute(player, "warp", emptyList(), runtime) + successful.compiled.execute(player, "warp", emptyList(), runtime) + + assertEquals(1, successfulExecutions) + assertTrue(sentMessages(player).any { it.contains("wait", ignoreCase = true) }) + } + + @Test + fun `suggestions include aliases option names and option values`() { + val alice = player("Alice") + val bob = player("Bob") + val runtime = runtime(players = listOf(alice, bob)) + + val spec = + command("island") { + sub("invite") { + aliases("add") + val target = player("target") + durationOption("expires", "e") + executePlayer { + target().name + } + } + } + + assertTrue(spec.compiled.suggest(alice, listOf("a"), runtime).contains("add")) + assertTrue(spec.compiled.suggest(alice, listOf("invite", "--e"), runtime).contains("--expires")) + assertTrue(spec.compiled.suggest(alice, listOf("invite", "--expires", ""), runtime).contains("30m")) + assertTrue(spec.compiled.suggest(alice, listOf("invite", ""), runtime).contains("Alice")) + } + + @Test + fun `custom parser suggestions can use previous parsed arguments`() { + val player = player("Alice") + val runtime = runtime(players = listOf(player)) + + val dynamicParser = + object : DaisyParser { + override val displayName: String = "name" + + override fun parse( + input: String, + context: ParseContext, + ): ParseResult = ParseResult.success(input) + + override fun suggest(context: SuggestContext): List = + when (context.previousArguments["scope"]) { + "public" -> listOf("spawn", "market") + "private" -> listOf("vault", "chest") + else -> emptyList() + } + } + + val spec = + command("warp") { + sub("set") { + choice("scope", "public", "private") + argument("name", dynamicParser) + executePlayer { } + } + } + + assertEquals(listOf("spawn", "market"), spec.compiled.suggest(player, listOf("set", "public", ""), runtime)) + } + + @Test + fun `custom message hooks are applied`() { + val player = player("Alice") + val runtime = + runtime( + players = listOf(player), + config = + DaisyConfig( + messages = + DaisyMessages().apply { + prefix = "" + argumentErrorRenderer = + DaisyArgumentErrorRenderer { context, _ -> + "Custom error: ${context.message}" + } + }, + ), + ) + + val spec = + command("coins") { + int("amount") + executePlayer { } + } + + spec.compiled.execute(player, "coins", listOf("oops"), runtime) + assertTrue(sentMessages(player).any { it.contains("Custom error:") }) + } + + @Test + fun `paper adapter delegates execution and suggestions`() { + val alice = player("Alice") + val bob = player("Bob") + val runtime = runtime(players = listOf(alice, bob)) + + var targetName = "" + val spec = + command("island") { + sub("invite") { + val target = player("target") + executePlayer { + targetName = target().name + } + } + } + + val adapter = PaperCommandAdapter(spec.compiled, runtime) + val stack = mock(io.papermc.paper.command.brigadier.CommandSourceStack::class.java) + `when`(stack.sender).thenReturn(alice) + + adapter.execute(stack, arrayOf("invite", "Bob")) + val suggestions = adapter.suggest(stack, arrayOf("inv")) + + assertEquals("Bob", targetName) + assertTrue(suggestions.contains("invite")) + } + + @Test + fun `invalid command structures fail fast`() { + val duplicateAlias = + command("root") { + sub("create") { + execute { } + } + sub("make") { + aliases("create") + execute { } + } + } + + assertThrows { + duplicateAlias.compiled + } + + val invalidArgs = + command("bad") { + text("message").optional() + string("required") + execute { } + } + + assertThrows { + invalidArgs.compiled + } + + val invalidOptions = + command("opt") { + stringOption("reason", "r") + stringOption("rename", "r") + execute { } + } + + assertThrows { + invalidOptions.compiled + } + } + + @Test + fun `readme style commands compile`() { + val spec = + command("island") { + description("Island management") + aliases("is") + + sub("create") { + playerOnly() + executePlayer { + reply("Island created for ${player.name}.") + } + } + + sub("invite") { + permission("island.invite") + val target = player("target") + val silent = flag("silent", "s") + val note = textOption("note").optional() + + executePlayer { + if (silent()) { + reply("Silent invite for ${target().name}: ${note() ?: "No note"}") + } + } + } + } + + assertEquals("island", spec.compiled.name) + assertEquals(listOf("is"), spec.aliases) + } + + private fun runtime( + players: List = emptyList(), + worlds: List = emptyList(), + offlinePlayers: List = emptyList(), + config: DaisyConfig = DaisyConfig(messages = DaisyMessages().apply { prefix = "" }), + ): CommandRuntime = + CommandRuntime( + logger = Logger.getLogger("DaisyCommandsTest"), + config = config, + platform = + object : DaisyPlatform { + private val playerMap = players.associateBy { it.name.lowercase() } + private val worldMap = worlds.associateBy { it.name.lowercase() } + private val offlineMap = offlinePlayers.associateBy { (it.name ?: "").lowercase() } + + override fun findPlayer(name: String): Player? = playerMap[name.lowercase()] + + override fun onlinePlayers(): Collection = players + + override fun findOfflinePlayer(name: String): OfflinePlayer? = offlineMap[name.lowercase()] + + override fun findWorld(name: String): World? = worldMap[name.lowercase()] + + override fun worlds(): Collection = worlds + }, + ) + + private fun player( + name: String, + permissions: Set = emptySet(), + ): Player { + val player = mock(Player::class.java) + val messages = mutableListOf() + + `when`(player.name).thenReturn(name) + `when`(player.uniqueId).thenReturn(UUID.nameUUIDFromBytes(name.toByteArray())) + `when`(player.hasPermission(any(String::class.java))).thenAnswer { invocation -> + permissions.contains(invocation.getArgument(0)) + } + + doAnswer { invocation -> + messages += plainSerializer.serialize(invocation.getArgument(0)) + null + }.`when`(player).sendMessage(any(Component::class.java)) + + messageStore[player] = messages + return player + } + + private fun sender( + name: String, + permissions: Set = emptySet(), + ): CommandSender { + val sender = mock(CommandSender::class.java) + val messages = mutableListOf() + + `when`(sender.name).thenReturn(name) + `when`(sender.hasPermission(any(String::class.java))).thenAnswer { invocation -> + permissions.contains(invocation.getArgument(0)) + } + doAnswer { invocation -> + messages += plainSerializer.serialize(invocation.getArgument(0)) + null + }.`when`(sender).sendMessage(any(Component::class.java)) + + messageStore[sender] = messages + return sender + } + + private fun sentMessages(sender: Any): List = messageStore[sender] ?: emptyList() + + companion object { + private val messageStore = mutableMapOf>() + } +}