commit dec60a1c463006bf4135dff8a72b98e0b3366168 Author: Hare Date: Fri Nov 28 06:00:55 2025 +0900 init: 1.0 diff --git a/.direnv/bin/nix-direnv-reload b/.direnv/bin/nix-direnv-reload new file mode 100755 index 0000000..c2c19a6 --- /dev/null +++ b/.direnv/bin/nix-direnv-reload @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +set -e +if [[ ! -d "/home/hare/Projects/crafters-toolbox/components/plugins/kommand-lib" ]]; then + echo "Cannot find source directory; Did you move it?" + echo "(Looking for "/home/hare/Projects/crafters-toolbox/components/plugins/kommand-lib")" + echo 'Cannot force reload with this script - use "direnv reload" manually and then try again' + exit 1 +fi + +# rebuild the cache forcefully +_nix_direnv_force_reload=1 direnv exec "/home/hare/Projects/crafters-toolbox/components/plugins/kommand-lib" true + +# Update the mtime for .envrc. +# This will cause direnv to reload again - but without re-building. +touch "/home/hare/Projects/crafters-toolbox/components/plugins/kommand-lib/.envrc" + +# Also update the timestamp of whatever profile_rc we have. +# This makes sure that we know we are up to date. +touch -r "/home/hare/Projects/crafters-toolbox/components/plugins/kommand-lib/.envrc" "/home/hare/Projects/crafters-toolbox/components/plugins/kommand-lib/.direnv"/*.rc diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..f91f646 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,12 @@ +# +# https://help.github.com/articles/dealing-with-line-endings/ +# +# Linux start script should use lf +/gradlew text eol=lf + +# These are Windows script files and should use crlf +*.bat text eol=crlf + +# Binary files should be left untouched +*.jar binary + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ba2fa11 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.direnv + +.gradle +build diff --git a/README.md b/README.md new file mode 100644 index 0000000..d67fb56 --- /dev/null +++ b/README.md @@ -0,0 +1,130 @@ +# Kommand Lib + +Paper/Bukkit サーバー向けのコマンド定義を DSL で記述するためのライブラリです。ルート定義から引数の型、補完、パーミッション伝播までを宣言的に表現でき、手続き的な `CommandExecutor` 実装を大きく簡略化します。 + +## 特徴 + +- Kotlin DSL で `command { literal { argument { ... } } }` のようにネストを表現 +- 型付き引数 (`string`, `integer`, `float`, `player`, `selector`, `coordinates` など) と検証ロジックを組み込み +- 1 つの定義から実行とタブ補完の両方を生成 +- パーミッションや条件をノード単位で宣言し、子ノードへ自動伝播 +- `suggests {}` で引数ごとの補完候補を柔軟に制御 + +## 依存関係 + +`build.gradle.kts` では Paper API と Kotlin 標準ライブラリのみを `compileOnly` に追加しています。Paper 1.21.10 対応の API を利用しています。 + +```kotlin +plugins { + kotlin("jvm") version "2.2.21" + id("de.eldoria.plugin-yml.paper") version "0.8.0" + id("com.gradleup.shadow") version "9.2.2" +} + +dependencies { + compileOnly("io.papermc.paper:paper-api:1.21.10-R0.1-SNAPSHOT") + compileOnly("org.jetbrains.kotlin:kotlin-stdlib") +} +``` + +## 使い方 + +1. プラグインの `onEnable` などで `kommand(plugin) { ... }` DSL を呼び出します。 +2. `command("root", "alias") { ... }` でコマンドを宣言し、`literal` や `string`/`integer` 引数を追加します。 +3. `executes { ... }` 内で `string("player")` や `int("amount")` を使ってパース済みの値を取得します。 + +### サンプル: 経済コマンド + +```kotlin +class EconomyPlugin : JavaPlugin() { + private lateinit var commands: KommandLib + + override fun onEnable() { + commands = kommand(this) { + command("eco", "economy") { + description = "Economy management" + permission = "example.eco" + + literal("give") { + player("target") // プレイヤー名 or セレクター (@p 等) + integer("amount", min = 1) + + executes { + val target = player("target") + val amount = int("amount") + sender.sendMessage("Giving $amount to ${target.name}") + } + } + + literal("speed") { + players("targets") // @a, プレイヤー名, などをまとめて取得 + float("value", min = 0.1, max = 5.0) + + executes { + val targets = players("targets") + val speed = float("value") + targets.forEach { it.walkSpeed = speed.toFloat() / 5.0f } + sender.sendMessage("Updated ${targets.size} players") + } + } + + literal("setspawn") { + coordinates("point") // "~ ~1 ~-2" のような入力を受け付ける + + executes { + val base = (sender as? Player)?.location ?: return@executes + val target = location("point", base) + plugin.server.worlds.first().setSpawnLocation(target) + sender.sendMessage("Spawn set to ${target.x}, ${target.y}, ${target.z}") + } + } + + literal("inspect") { + selector("entities") + + executes { + val entities = selector("entities") + sender.sendMessage("Selector resolved ${entities.size} entities") + } + } + } + } + } + + override fun onDisable() { + commands.unregister() + } +} +``` + +### DSL 構文のポイント + +- `literal("sub") { ... }` は固定語句を表すノードです。`requires("permission.node")` でその枝のみにパーミッションを設定できます。 +- `string("name")` や `integer("value", min = 0)` は値をパースし、成功すると `KommandContext` に記憶されます。 +- `float("speed")` は倍精度を、`player("target")`/`players("targets")`/`selector("entities")` は Minecraft の標準セレクター (`@p`, `@a`, `@s` など) やプレイヤー名を型付きで扱えます。 +- `suggests { prefix -> ... }` を指定すると、タブ補完時に任意の候補リストを返せます。 +- `coordinates("pos")` は `x y z` をまとめて 1 つの引数として受け取り、`location("pos", player.location)` で現在位置を基準に解決できます (`~` を使用した相対座標に対応)。 +- `command` や各ノードの `condition { sender -> ... }` で実行条件 (例: コンソール禁止) を追加できます。 +- ルートレベルで `executes { ... }` を指定すると、引数なしで `/eco` を実行した場合に呼び出されます。 + +## 組み込み引数の一覧 + +| DSL | 返り値 | 補足 | +| --- | --- | --- | +| `string("name")` | `String` | 任意のトークン。`suggests {}` で補完可 | +| `integer("value", min, max)` | `Int` | 範囲チェック付き | +| `float("speed", min, max)` | `Double` | 小数/指数表記に対応 | +| `player("target", allowSelectors = true)` | `Player` | `@p` などのセレクターまたはプレイヤー名を 1 人に解決 | +| `players("targets")` | `List` | `@a`/`@r` など複数指定、プレイヤー名入力も可 | +| `selector("entities")` | `List` | Bukkit の `Bukkit.selectEntities` をそのまま利用 | +| `coordinates("pos")` | `Coordinates3` | `~` 相対座標を含む 3 軸をまとめて扱う | + +`Coordinates3` は `coordinates("pos") { ... }` 直後のコンテキストで `coordinates("pos")` もしくは `location("pos", baseLocation)` として取得できます。 + +## ビルドとテスト + +```bash +./gradlew build +``` + +ShadowJar タスクが実行され、`build/libs` に出力されます。Paper サーバーに配置して動作確認してください。 diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..4eb4bd5 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,39 @@ +import net.minecrell.pluginyml.bukkit.BukkitPluginDescription + +group = "net.hareworks.hcu" +version = "1.0" + +plugins { + kotlin("jvm") version "2.2.21" + id("de.eldoria.plugin-yml.paper") version "0.8.0" + id("com.gradleup.shadow") version "9.2.2" +} +repositories { + mavenCentral() + maven("https://repo.papermc.io/repository/maven-public/") +} + +val exposedVersion = "1.0.0-rc-3" + +dependencies { + compileOnly("io.papermc.paper:paper-api:1.21.10-R0.1-SNAPSHOT") + compileOnly("org.jetbrains.kotlin:kotlin-stdlib") +} +tasks { + shadowJar { + archiveBaseName.set("economy") + archiveClassifier.set("") + } +} + +paper { + main = "net.hareworks.kommand_lib.App" + name = "kommand-lib" + description = "Command library" + version = getVersion().toString() + apiVersion = "1.21.10" + authors = + listOf( + "Hare-K02" + ) +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..742cdd6 --- /dev/null +++ b/flake.lock @@ -0,0 +1,61 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1764020296, + "narHash": "sha256-6zddwDs2n+n01l+1TG6PlyokDdXzu/oBmEejcH5L5+A=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "a320ce8e6e2cc6b4397eef214d202a50a4583829", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-25.11", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..7f61bf2 --- /dev/null +++ b/flake.nix @@ -0,0 +1,47 @@ +{ + description = "Minecraft dev environment with JDK 21 and Gradle"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = + { + self, + nixpkgs, + flake-utils, + ... + }: + flake-utils.lib.eachDefaultSystem ( + system: + let + pkgs = import nixpkgs { + inherit system; + }; + in + { + devShells.default = pkgs.mkShell { + packages = with pkgs; [ + jdk21 + gradle + kotlin + git + unzip + ]; + + # 必要に応じて環境変数を設定 + shellHook = '' + export JAVA_HOME=${pkgs.jdk21}/lib/openjdk + export PATH="$JAVA_HOME/bin:$PATH" + + export GRADLE_USER_HOME="$PWD/.gradle" + + echo "Loaded Minecraft dev env (JDK 21 + Gradle)" + java -version || true + gradle --version || true + ''; + }; + } + ); +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..5154008 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,7 @@ +# This file was generated by the Gradle 'init' task. +# https://docs.gradle.org/current/userguide/build_environment.html#sec:gradle_configuration_properties + +org.gradle.configuration-cache=true +org.gradle.parallel=true +org.gradle.caching=true + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..4ac3234 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,2 @@ +# This file was generated by the Gradle 'init' task. +# https://docs.gradle.org/current/userguide/platforms.html#sub::toml-dependencies-format diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..1b33c55 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..d4081da --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..23d15a9 --- /dev/null +++ b/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..db3a6ac --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..292e9b0 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,9 @@ +/* + * This file was generated by the Gradle 'init' task. + * + * The settings file is used to specify which projects to include in your build. + * For more detailed information on multi-project builds, please refer to https://docs.gradle.org/8.14.3/userguide/multi_project_builds.html in the Gradle documentation. + * This project uses @Incubating APIs which are subject to change. + */ + +rootProject.name = "kommand-lib" diff --git a/src/main/kotlin/net/hareworks/kommand-lib/App.kt b/src/main/kotlin/net/hareworks/kommand-lib/App.kt new file mode 100644 index 0000000..38514f6 --- /dev/null +++ b/src/main/kotlin/net/hareworks/kommand-lib/App.kt @@ -0,0 +1,14 @@ +package net.hareworks.kommand_lib; + +import org.bukkit.plugin.java.JavaPlugin + +public class App : JavaPlugin() { + companion object { + lateinit var instance: App + private set + } + + override fun onEnable() { + instance = this + } +} diff --git a/src/main/kotlin/net/hareworks/kommand-lib/Kommand.kt b/src/main/kotlin/net/hareworks/kommand-lib/Kommand.kt new file mode 100644 index 0000000..5c6a366 --- /dev/null +++ b/src/main/kotlin/net/hareworks/kommand-lib/Kommand.kt @@ -0,0 +1,129 @@ +package net.hareworks.kommand_lib + +import net.hareworks.kommand_lib.context.KommandContext +import net.hareworks.kommand_lib.execution.CommandTree +import net.hareworks.kommand_lib.execution.ParseMode +import net.hareworks.kommand_lib.dsl.KommandRegistry +import org.bukkit.Bukkit +import org.bukkit.command.CommandMap +import org.bukkit.command.CommandSender +import org.bukkit.command.PluginCommand +import org.bukkit.command.TabCompleter +import org.bukkit.plugin.java.JavaPlugin + +/** + * Entry-point for registering commands via the Kommand DSL. + * + * Example: + * ```kotlin + * kommand(plugin) { + * command("eco", "economy") { + * description = "Economy management" + * permission = "example.eco" + * + * literal("give") { + * string("player") + * integer("amount", min = 0) { + * executes { + * val targetName = string("player") + * val amount = int("amount") + * sender.sendMessage("Giving $amount to $targetName") + * } + * } + * } + * } + * } + * ``` + */ +fun kommand(plugin: JavaPlugin, block: KommandRegistry.() -> Unit): KommandLib { + val registry = KommandRegistry(plugin) + registry.block() + return registry.build() +} + +/** + * Manages the lifecycle of the commands registered through the DSL. + */ +class KommandLib internal constructor( + private val plugin: JavaPlugin, + private val definitions: List +) { + private val commandMap: CommandMap by lazy { + val field = Bukkit.getServer().javaClass.getDeclaredField("commandMap") + field.isAccessible = true + field.get(Bukkit.getServer()) as CommandMap + } + private val registered = mutableListOf() + + init { + registerAll() + } + + private fun registerAll() { + for (definition in definitions) { + commandMap.getCommand(definition.name)?.unregister(commandMap) + val command = newPluginCommand(definition.name) + if (definition.aliases.isNotEmpty()) command.aliases = definition.aliases + definition.description?.let { command.description = it } + definition.usage?.let { command.usage = it } + definition.permission?.let { command.permission = it } + + command.setExecutor { sender, _, alias, args -> + definition.execute(plugin, sender, alias, args) + } + command.tabCompleter = TabCompleter { sender, _, alias, args -> + definition.tabComplete(plugin, sender, alias, args) + } + commandMap.register(plugin.name.lowercase(), command) + registered += command + } + } + + fun unregister() { + registered.forEach { it.unregister(commandMap) } + registered.clear() + } + + private fun newPluginCommand(name: String): PluginCommand { + val constructor = PluginCommand::class.java.getDeclaredConstructor(String::class.java, JavaPlugin::class.java) + constructor.isAccessible = true + return constructor.newInstance(name, plugin) + } +} + +internal data class CommandDefinition( + val name: String, + val aliases: List, + val description: String?, + val usage: String?, + val permission: String?, + val rootCondition: (CommandSender) -> Boolean, + val rootExecutor: (KommandContext.() -> Unit)?, + val nodes: List +) { + private val tree = CommandTree(nodes) + + fun execute(plugin: JavaPlugin, sender: CommandSender, alias: String, args: Array): Boolean { + if (!rootCondition(sender)) return false + val context = KommandContext(plugin, sender, alias, args, ParseMode.EXECUTE) + if (args.isEmpty()) { + val executor = rootExecutor ?: return false + executor.invoke(context) + return true + } + return tree.execute(context) + } + + fun tabComplete(plugin: JavaPlugin, sender: CommandSender, alias: String, args: Array): List { + if (!rootCondition(sender)) return emptyList() + if (nodes.isEmpty()) return emptyList() + val context = KommandContext(plugin, sender, alias, args, ParseMode.SUGGEST) + if (args.isEmpty()) { + return nodes + .filter { it.isVisible(sender) } + .flatMap { it.suggestions("", context) } + .distinct() + } + return tree.tabComplete(context) + } +} diff --git a/src/main/kotlin/net/hareworks/kommand-lib/arguments/ArgumentTypes.kt b/src/main/kotlin/net/hareworks/kommand-lib/arguments/ArgumentTypes.kt new file mode 100644 index 0000000..ee6393b --- /dev/null +++ b/src/main/kotlin/net/hareworks/kommand-lib/arguments/ArgumentTypes.kt @@ -0,0 +1,197 @@ +package net.hareworks.kommand_lib.arguments + +import net.hareworks.kommand_lib.context.KommandContext +import org.bukkit.Bukkit +import org.bukkit.Location +import org.bukkit.entity.Entity +import org.bukkit.entity.Player + +sealed class ArgumentParseResult { + data class Success(val value: T) : ArgumentParseResult() + data class Failure(val reason: String) : ArgumentParseResult() +} + +interface KommandArgumentType { + fun parse(input: String, context: KommandContext): ArgumentParseResult + fun suggestions(context: KommandContext, prefix: String): List = emptyList() +} + +object WordArgumentType : KommandArgumentType { + override fun parse(input: String, context: KommandContext): ArgumentParseResult { + if (input.isBlank()) return ArgumentParseResult.Failure("Value cannot be blank") + return ArgumentParseResult.Success(input) + } +} + +class IntegerArgumentType( + private val min: Int? = null, + private val max: Int? = null +) : KommandArgumentType { + override fun parse(input: String, context: KommandContext): ArgumentParseResult { + val value = input.toIntOrNull() + ?: return ArgumentParseResult.Failure("Expected integer but got '$input'") + if (min != null && value < min) { + return ArgumentParseResult.Failure("Value must be >= $min") + } + if (max != null && value > max) { + return ArgumentParseResult.Failure("Value must be <= $max") + } + return ArgumentParseResult.Success(value) + } +} + +class FloatArgumentType( + private val min: Double? = null, + private val max: Double? = null +) : KommandArgumentType { + override fun parse(input: String, context: KommandContext): ArgumentParseResult { + val value = input.toDoubleOrNull() + ?: return ArgumentParseResult.Failure("Expected decimal number but got '$input'") + if (min != null && value < min) { + return ArgumentParseResult.Failure("Value must be >= $min") + } + if (max != null && value > max) { + return ArgumentParseResult.Failure("Value must be <= $max") + } + return ArgumentParseResult.Success(value) + } +} + +class PlayerArgumentType( + private val allowSelectors: Boolean +) : KommandArgumentType { + override fun parse(input: String, context: KommandContext): ArgumentParseResult { + val trimmed = input.trim() + if (allowSelectors && trimmed.startsWith("@")) { + val entities = try { + Bukkit.selectEntities(context.sender, trimmed) + } catch (ex: IllegalArgumentException) { + return ArgumentParseResult.Failure(ex.message ?: "Invalid selector '$trimmed'") + } + val player = entities.firstOrNull { it is Player } as? Player + ?: return ArgumentParseResult.Failure("Selector '$trimmed' did not match a player") + return ArgumentParseResult.Success(player) + } + val player = Bukkit.getPlayerExact(trimmed) + ?: return ArgumentParseResult.Failure("Player '$trimmed' is not online") + return ArgumentParseResult.Success(player) + } + + override fun suggestions(context: KommandContext, prefix: String): List { + val names = Bukkit.getOnlinePlayers() + .map { it.name } + .filter { it.startsWith(prefix, ignoreCase = true) } + if (!allowSelectors) return names + val selectors = DEFAULT_SELECTOR_SUGGESTIONS.filter { it.startsWith(prefix) } + return (names + selectors).distinct() + } +} + +class PlayerSelectorArgumentType( + private val allowDirectNames: Boolean +) : KommandArgumentType> { + override fun parse(input: String, context: KommandContext): ArgumentParseResult> { + val trimmed = input.trim() + if (!trimmed.startsWith("@")) { + if (!allowDirectNames) { + return ArgumentParseResult.Failure("Selector expected but got '$trimmed'") + } + val player = Bukkit.getPlayerExact(trimmed) + ?: return ArgumentParseResult.Failure("Player '$trimmed' is not online") + return ArgumentParseResult.Success(listOf(player)) + } + + val entities = try { + Bukkit.selectEntities(context.sender, trimmed) + } catch (ex: IllegalArgumentException) { + return ArgumentParseResult.Failure(ex.message ?: "Invalid selector '$trimmed'") + } + val players = entities.filterIsInstance() + if (players.isEmpty()) { + return ArgumentParseResult.Failure("Selector '$trimmed' did not match any players") + } + return ArgumentParseResult.Success(players) + } + + override fun suggestions(context: KommandContext, prefix: String): List { + val candidates = linkedSetOf() + candidates += Bukkit.getOnlinePlayers() + .map { it.name } + .filter { it.startsWith(prefix, ignoreCase = true) } + candidates += DEFAULT_SELECTOR_SUGGESTIONS.filter { it.startsWith(prefix) } + return candidates.toList() + } +} + +class EntitySelectorArgumentType( + private val requireMatch: Boolean +) : KommandArgumentType> { + override fun parse(input: String, context: KommandContext): ArgumentParseResult> { + val trimmed = input.trim() + if (!trimmed.startsWith("@")) { + val player = Bukkit.getPlayerExact(trimmed) + if (player != null) return ArgumentParseResult.Success(listOf(player)) + return if (requireMatch) { + ArgumentParseResult.Failure("No entity matched '$trimmed'") + } else { + ArgumentParseResult.Success(emptyList()) + } + } + + val entities = try { + Bukkit.selectEntities(context.sender, trimmed) + } catch (ex: IllegalArgumentException) { + return ArgumentParseResult.Failure(ex.message ?: "Invalid selector '$trimmed'") + } + if (requireMatch && entities.isEmpty()) { + return ArgumentParseResult.Failure("Selector '$trimmed' did not match any entities") + } + return ArgumentParseResult.Success(entities) + } + + override fun suggestions(context: KommandContext, prefix: String): List { + return DEFAULT_SELECTOR_SUGGESTIONS.filter { it.startsWith(prefix) } + } +} + +class CoordinateComponentArgumentType( + private val allowRelative: Boolean +) : KommandArgumentType { + override fun parse(input: String, context: KommandContext): ArgumentParseResult { + val trimmed = input.trim() + if (allowRelative && trimmed.startsWith("~")) { + val remainder = trimmed.removePrefix("~") + val offset = if (remainder.isEmpty()) 0.0 else remainder.toDoubleOrNull() + ?: return ArgumentParseResult.Failure("Invalid relative coordinate '$trimmed'") + return ArgumentParseResult.Success(CoordinateComponent(relative = true, value = offset)) + } + val absolute = trimmed.toDoubleOrNull() + ?: return ArgumentParseResult.Failure("Expected coordinate but got '$trimmed'") + return ArgumentParseResult.Success(CoordinateComponent(relative = false, value = absolute)) + } + + override fun suggestions(context: KommandContext, prefix: String): List { + if (!allowRelative) return emptyList() + return if ("~".startsWith(prefix)) listOf("~") else emptyList() + } +} + +private val DEFAULT_SELECTOR_SUGGESTIONS = listOf("@p", "@a", "@s", "@r", "@e") + +data class CoordinateComponent(val relative: Boolean, val value: Double) { + fun resolve(origin: Double): Double = if (relative) origin + value else value +} + +data class Coordinates3( + val x: CoordinateComponent, + val y: CoordinateComponent, + val z: CoordinateComponent +) { + fun resolve(origin: Location): Location { + val clone = origin.clone() + clone.x = x.resolve(clone.x) + clone.y = y.resolve(clone.y) + clone.z = z.resolve(clone.z) + return clone + } +} diff --git a/src/main/kotlin/net/hareworks/kommand-lib/context/KommandContext.kt b/src/main/kotlin/net/hareworks/kommand-lib/context/KommandContext.kt new file mode 100644 index 0000000..6fd7798 --- /dev/null +++ b/src/main/kotlin/net/hareworks/kommand-lib/context/KommandContext.kt @@ -0,0 +1,55 @@ +package net.hareworks.kommand_lib.context + +import net.hareworks.kommand_lib.arguments.Coordinates3 +import org.bukkit.Location +import org.bukkit.command.CommandSender +import org.bukkit.entity.Entity +import org.bukkit.entity.Player +import org.bukkit.plugin.java.JavaPlugin +import net.hareworks.kommand_lib.execution.ParseMode + +open class KommandContext internal constructor( + val plugin: JavaPlugin, + val sender: CommandSender, + val alias: String, + val args: Array, + val mode: ParseMode +) { + private val parsedArguments = linkedMapOf() + + internal fun remember(name: String, value: Any?) { + parsedArguments[name] = value + } + + internal fun drop(name: String) { + parsedArguments.remove(name) + } + + @Suppress("UNCHECKED_CAST") + fun argument(name: String): T = + parsedArguments[name] as? T + ?: error("Argument '$name' is not present in this context.") + + @Suppress("UNCHECKED_CAST") + fun argumentOrNull(name: String): T? = parsedArguments[name] as? T + + fun arguments(): Map = parsedArguments.toMap() +} + +fun KommandContext.string(name: String): String = argument(name) + +fun KommandContext.int(name: String): Int = argument(name) + +fun KommandContext.double(name: String): Double = argument(name) + +fun KommandContext.float(name: String): Double = argument(name) + +fun KommandContext.player(name: String): Player = argument(name) + +fun KommandContext.players(name: String): List = argument(name) + +fun KommandContext.selector(name: String): List = argument(name) + +fun KommandContext.coordinates(name: String): Coordinates3 = argument(name) + +fun KommandContext.location(name: String, origin: Location): Location = coordinates(name).resolve(origin) diff --git a/src/main/kotlin/net/hareworks/kommand-lib/dsl/RegistryBuilders.kt b/src/main/kotlin/net/hareworks/kommand-lib/dsl/RegistryBuilders.kt new file mode 100644 index 0000000..79883b9 --- /dev/null +++ b/src/main/kotlin/net/hareworks/kommand-lib/dsl/RegistryBuilders.kt @@ -0,0 +1,198 @@ +package net.hareworks.kommand_lib.dsl + +import net.hareworks.kommand_lib.CommandDefinition +import net.hareworks.kommand_lib.arguments.* +import net.hareworks.kommand_lib.nodes.Axis +import net.hareworks.kommand_lib.nodes.CoordinateAxisNode +import net.hareworks.kommand_lib.nodes.KommandNode +import net.hareworks.kommand_lib.nodes.LiteralNode +import net.hareworks.kommand_lib.nodes.ValueNode +import org.bukkit.command.CommandSender +import org.bukkit.entity.Entity +import org.bukkit.entity.Player +import org.bukkit.plugin.java.JavaPlugin + +@KommandDsl +class KommandRegistry internal constructor(private val plugin: JavaPlugin) { + private val definitions = mutableListOf() + + /** + * Declares a new command root. + */ + fun command(name: String, vararg aliases: String, block: CommandBuilder.() -> Unit) { + val builder = CommandBuilder(name, aliases.toList()) + builder.block() + definitions += builder.build() + } + + fun command(name: String, aliases: Iterable, block: CommandBuilder.() -> Unit) { + val builder = CommandBuilder(name, aliases.toList()) + builder.block() + definitions += builder.build() + } + + internal fun build(): net.hareworks.kommand_lib.KommandLib = + net.hareworks.kommand_lib.KommandLib(plugin, definitions.toList()) +} + +@KommandDsl +class CommandBuilder internal constructor( + val name: String, + val aliases: List +) : BranchScope(mutableListOf()) { + var description: String? = null + var usage: String? = null + var permission: String? = null + + private var condition: (CommandSender) -> Boolean = { true } + private var rootExecutor: (net.hareworks.kommand_lib.context.KommandContext.() -> Unit)? = null + + fun condition(predicate: (CommandSender) -> Boolean) { + condition = predicate + } + + fun executes(block: net.hareworks.kommand_lib.context.KommandContext.() -> Unit) { + rootExecutor = block + } + + override val inheritedPermission: String? + get() = permission + + override val inheritedCondition: (CommandSender) -> Boolean + get() = condition + + internal fun build(): CommandDefinition = + CommandDefinition( + name = name, + aliases = aliases, + description = description, + usage = usage, + permission = permission, + rootCondition = condition, + rootExecutor = rootExecutor, + nodes = children.toList() + ) +} + +@KommandDsl +abstract class BranchScope internal constructor( + protected val children: MutableList +) { + protected abstract val inheritedPermission: String? + protected abstract val inheritedCondition: (CommandSender) -> Boolean + + fun literal(name: String, block: LiteralBuilder.() -> Unit = {}) { + val node = LiteralNode(name) + node.permission = inheritedPermission + node.condition = inheritedCondition + children += node + LiteralBuilder(node).apply(block) + } + + fun argument(name: String, type: KommandArgumentType, block: ValueBuilder.() -> Unit = {}) { + val node = ValueNode(name, type) + node.permission = inheritedPermission + node.condition = inheritedCondition + children += node + ValueBuilder(node).apply(block) + } + + fun string(name: String, block: ValueBuilder.() -> Unit = {}) = argument(name, WordArgumentType, block) + + fun integer( + name: String, + min: Int? = null, + max: Int? = null, + block: ValueBuilder.() -> Unit = {} + ) = argument(name, IntegerArgumentType(min, max), block) + + fun float( + name: String, + min: Double? = null, + max: Double? = null, + block: ValueBuilder.() -> Unit = {} + ) = argument(name, FloatArgumentType(min, max), block) + + fun player( + name: String, + allowSelectors: Boolean = true, + block: ValueBuilder.() -> Unit = {} + ) = argument(name, PlayerArgumentType(allowSelectors), block) + + fun players( + name: String, + allowDirectNames: Boolean = true, + block: ValueBuilder>.() -> Unit = {} + ) = argument(name, PlayerSelectorArgumentType(allowDirectNames), block) + + fun selector( + name: String, + requireMatch: Boolean = true, + block: ValueBuilder>.() -> Unit = {} + ) = argument(name, EntitySelectorArgumentType(requireMatch), block) + + fun coordinates( + name: String, + allowRelative: Boolean = true, + block: CoordinateNodeScope.() -> Unit = {} + ) { + val xNode = CoordinateAxisNode(name, Axis.X, allowRelative) + val yNode = CoordinateAxisNode(name, Axis.Y, allowRelative) + val zNode = CoordinateAxisNode(name, Axis.Z, allowRelative) + val nodes = listOf(xNode, yNode, zNode) + nodes.forEach { node -> + node.permission = inheritedPermission + node.condition = inheritedCondition + } + xNode.children += yNode + yNode.children += zNode + children += xNode + CoordinateNodeScope(zNode).apply(block) + } +} + +@KommandDsl +abstract class NodeScope internal constructor( + protected val node: KommandNode +) : BranchScope(node.children) { + override val inheritedPermission: String? + get() = node.permission + + override val inheritedCondition: (CommandSender) -> Boolean + get() = node.condition + + fun requires(permission: String) { + node.permission = permission + } + + fun condition(predicate: (CommandSender) -> Boolean) { + node.condition = predicate + } + + fun executes(block: net.hareworks.kommand_lib.context.KommandContext.() -> Unit) { + node.executor = block + } +} + +@KommandDsl +class LiteralBuilder internal constructor( + node: LiteralNode +) : NodeScope(node) + +@KommandDsl +class ValueBuilder internal constructor( + private val valueNode: ValueNode +) : NodeScope(valueNode) { + /** + * Overrides the default suggestion provider for this argument. + */ + fun suggests(block: net.hareworks.kommand_lib.context.KommandContext.(prefix: String) -> List) { + valueNode.suggestionProvider = { ctx, prefix -> block(ctx, prefix) } + } +} + +@KommandDsl +class CoordinateNodeScope internal constructor(node: KommandNode) : NodeScope(node) + +@DslMarker +annotation class KommandDsl diff --git a/src/main/kotlin/net/hareworks/kommand-lib/execution/CommandTree.kt b/src/main/kotlin/net/hareworks/kommand-lib/execution/CommandTree.kt new file mode 100644 index 0000000..90136d4 --- /dev/null +++ b/src/main/kotlin/net/hareworks/kommand-lib/execution/CommandTree.kt @@ -0,0 +1,59 @@ +package net.hareworks.kommand_lib.execution + +import net.hareworks.kommand_lib.context.KommandContext +import net.hareworks.kommand_lib.nodes.KommandNode + +internal class CommandTree(private val roots: List) { + fun execute(context: KommandContext): Boolean { + if (context.args.isEmpty()) return false + val node = match(roots, context, 0) ?: return false + val executor = node.executor ?: return false + executor.invoke(context) + return true + } + + fun tabComplete(context: KommandContext): List { + if (context.args.isEmpty()) return emptyList() + return collect(roots, context, 0) + } + + private fun match(nodes: List, context: KommandContext, index: Int): KommandNode? { + if (index >= context.args.size) return null + val token = context.args[index] + for (node in nodes) { + if (!node.isVisible(context.sender)) continue + if (!node.consume(token, context, ParseMode.EXECUTE)) continue + if (index == context.args.lastIndex && node.executor != null) { + return node + } + val result = match(node.children, context, index + 1) + if (result != null) return result + node.undo(context) + } + return null + } + + private fun collect(nodes: List, context: KommandContext, index: Int): List { + val token = context.args[index] + val last = index == context.args.lastIndex + val suggestions = linkedSetOf() + for (node in nodes) { + if (!node.isVisible(context.sender)) continue + if (last) { + suggestions += node.suggestions(token, context) + } + if (node.consume(token, context, ParseMode.SUGGEST)) { + if (!last && node.children.isNotEmpty()) { + suggestions += collect(node.children, context, index + 1) + } + node.undo(context) + } + } + return suggestions.toList() + } +} + +enum class ParseMode { + EXECUTE, + SUGGEST +} diff --git a/src/main/kotlin/net/hareworks/kommand-lib/nodes/Nodes.kt b/src/main/kotlin/net/hareworks/kommand-lib/nodes/Nodes.kt new file mode 100644 index 0000000..d7d2d8a --- /dev/null +++ b/src/main/kotlin/net/hareworks/kommand-lib/nodes/Nodes.kt @@ -0,0 +1,98 @@ +package net.hareworks.kommand_lib.nodes + +import net.hareworks.kommand_lib.arguments.CoordinateComponent +import net.hareworks.kommand_lib.arguments.Coordinates3 +import net.hareworks.kommand_lib.arguments.KommandArgumentType +import net.hareworks.kommand_lib.context.KommandContext +import net.hareworks.kommand_lib.execution.ParseMode +import org.bukkit.command.CommandSender + +abstract class KommandNode internal constructor() { + val children: MutableList = mutableListOf() + var executor: (KommandContext.() -> Unit)? = null + var permission: String? = null + var condition: (CommandSender) -> Boolean = { true } + + fun isVisible(sender: CommandSender): Boolean { + val perm = permission + if (!perm.isNullOrBlank() && !sender.hasPermission(perm)) return false + return condition(sender) + } + + abstract fun consume(token: String, context: KommandContext, mode: ParseMode): Boolean + open fun undo(context: KommandContext) {} + abstract fun suggestions(prefix: String, context: KommandContext): List +} + +class LiteralNode internal constructor(private val literal: String) : KommandNode() { + override fun consume(token: String, context: KommandContext, mode: ParseMode): Boolean { + return literal.equals(token, ignoreCase = true) + } + + override fun suggestions(prefix: String, context: KommandContext): List { + return if (literal.startsWith(prefix, ignoreCase = true)) listOf(literal) else emptyList() + } +} + +open class ValueNode internal constructor( + private val name: String, + private val type: KommandArgumentType +) : KommandNode() { + var suggestionProvider: ((KommandContext, String) -> List)? = null + + override fun consume(token: String, context: KommandContext, mode: ParseMode): Boolean { + return when (val result = type.parse(token, context)) { + is net.hareworks.kommand_lib.arguments.ArgumentParseResult.Success -> { + context.remember(name, result.value) + true + } + is net.hareworks.kommand_lib.arguments.ArgumentParseResult.Failure -> { + if (mode == ParseMode.EXECUTE) { + context.sender.sendMessage(result.reason) + } + false + } + } + } + + override fun undo(context: KommandContext) { + context.drop(name) + } + + override fun suggestions(prefix: String, context: KommandContext): List { + val custom = suggestionProvider?.invoke(context, prefix) + if (custom != null) return custom + return type.suggestions(context, prefix) + } +} + +private fun coordinateAxisKey(base: String, axis: Axis): String = "$base::__${axis.name.lowercase()}" + +class CoordinateAxisNode( + private val aggregateName: String, + private val axis: Axis, + allowRelative: Boolean +) : ValueNode(coordinateAxisKey(aggregateName, axis), + net.hareworks.kommand_lib.arguments.CoordinateComponentArgumentType(allowRelative)) { + override fun consume(token: String, context: KommandContext, mode: ParseMode): Boolean { + val success = super.consume(token, context, mode) + if (success && axis == Axis.Z) { + val x = context.argumentOrNull(coordinateAxisKey(aggregateName, Axis.X)) + val y = context.argumentOrNull(coordinateAxisKey(aggregateName, Axis.Y)) + val z = context.argumentOrNull(coordinateAxisKey(aggregateName, Axis.Z)) + if (x != null && y != null && z != null) { + context.remember(aggregateName, Coordinates3(x, y, z)) + } + } + return success + } + + override fun undo(context: KommandContext) { + if (axis == Axis.Z) { + context.drop(aggregateName) + } + super.undo(context) + } +} + +enum class Axis { X, Y, Z }