diff --git a/src/main/java/ch/njol/skript/Skript.java b/src/main/java/ch/njol/skript/Skript.java index d44ee843f79..17a7b23d528 100644 --- a/src/main/java/ch/njol/skript/Skript.java +++ b/src/main/java/ch/njol/skript/Skript.java @@ -12,6 +12,7 @@ import ch.njol.skript.classes.data.JavaClasses; import ch.njol.skript.classes.data.SkriptClasses; import ch.njol.skript.command.Commands; +import org.skriptlang.skript.bukkit.command.BrigadierModule; import ch.njol.skript.doc.Documentation; import ch.njol.skript.events.EvtSkript; import ch.njol.skript.hooks.Hook; @@ -101,6 +102,7 @@ import org.skriptlang.skript.bukkit.registration.BukkitRegistryKeys; import org.skriptlang.skript.bukkit.registration.BukkitSyntaxInfos; import org.skriptlang.skript.bukkit.tags.TagModule; +import org.skriptlang.skript.lang.command.ArgumentTypeElement; import org.skriptlang.skript.lang.comparator.Comparator; import org.skriptlang.skript.lang.comparator.Comparators; import org.skriptlang.skript.lang.converter.Converter; @@ -586,6 +588,7 @@ public void onEnable() { FurnaceModule.load(); LootTableModule.load(); skript.loadModules(new DamageSourceModule()); + skript.loadModules(new BrigadierModule()); } catch (final Exception e) { exception(e, "Could not load required .class files: " + e.getLocalizedMessage()); setEnabled(false); diff --git a/src/main/java/ch/njol/skript/command/CommandUsage.java b/src/main/java/ch/njol/skript/command/CommandUsage.java index 8d27f74a87c..112a1b17700 100644 --- a/src/main/java/ch/njol/skript/command/CommandUsage.java +++ b/src/main/java/ch/njol/skript/command/CommandUsage.java @@ -7,20 +7,12 @@ /** * Holds info about the usage of a command. - * TODO: replace with record when java 17 + * + * @param usage a dynamic usage message that can contain expressions + * @param defaultUsage a fallback usage message that can be used in non-event environments + * like when registering the Bukkit command */ -public class CommandUsage { - - /** - * A dynamic usage message that can contain expressions. - */ - private final VariableString usage; - - /** - * A fallback usage message that can be used in non-event environments, - * like when registering the Bukkit command. - */ - private final String defaultUsage; +public record CommandUsage(VariableString usage, String defaultUsage) { /** * @param usage The dynamic usage message, can contain expressions. diff --git a/src/main/java/ch/njol/skript/test/runner/StructTestEntryContainer.java b/src/main/java/ch/njol/skript/test/runner/StructTestEntryContainer.java index 8780e67fb4b..987efb1b162 100644 --- a/src/main/java/ch/njol/skript/test/runner/StructTestEntryContainer.java +++ b/src/main/java/ch/njol/skript/test/runner/StructTestEntryContainer.java @@ -23,6 +23,7 @@ public class StructTestEntryContainer extends Structure { Skript.registerStructure(StructTestEntryContainer.class, EntryValidator.builder() .addSection("has entry", true) + .addSection("has multiple entry", true, true) .build(), "test entry container"); } @@ -30,10 +31,12 @@ public class StructTestEntryContainer extends Structure { private EntryContainer entryContainer; @Override - public boolean init(Literal[] args, int matchedPattern, ParseResult parseResult, @Nullable EntryContainer entryContainer) { + public boolean init( + Literal[] args, int matchedPattern, ParseResult parseResult, @Nullable EntryContainer entryContainer + ) { assert entryContainer != null; this.entryContainer = entryContainer; - if (entryContainer.hasEntry("has entry")) { + if (entryContainer.hasEntry("has entry") && entryContainer.hasEntry("has multiple entry")) { return true; } assert false; @@ -47,6 +50,16 @@ public boolean load() { Script script = getParser().getCurrentScript(); Trigger trigger = new Trigger(script, "entry container test", null, triggerItems); trigger.execute(new SkriptTestEvent()); + + List multipleSections = entryContainer.getAll( + "has multiple entry", SectionNode.class, false + ); + for (SectionNode multipleSection : multipleSections) { + triggerItems = ScriptLoader.loadItems(multipleSection); + trigger = new Trigger(script, "entry container test", null, triggerItems); + trigger.execute(new SkriptTestEvent()); + } + return true; } diff --git a/src/main/java/org/skriptlang/skript/brigadier/ArgumentSkriptCommandNode.java b/src/main/java/org/skriptlang/skript/brigadier/ArgumentSkriptCommandNode.java new file mode 100644 index 00000000000..cfb81f593ea --- /dev/null +++ b/src/main/java/org/skriptlang/skript/brigadier/ArgumentSkriptCommandNode.java @@ -0,0 +1,197 @@ +package org.skriptlang.skript.brigadier; + +import ch.njol.skript.lang.VariableString; +import com.mojang.brigadier.Command; +import com.mojang.brigadier.RedirectModifier; +import com.mojang.brigadier.StringReader; +import com.mojang.brigadier.arguments.ArgumentType; +import com.mojang.brigadier.builder.RequiredArgumentBuilder; +import com.mojang.brigadier.context.CommandContext; +import com.mojang.brigadier.context.CommandContextBuilder; +import com.mojang.brigadier.exceptions.CommandSyntaxException; +import com.mojang.brigadier.suggestion.SuggestionProvider; +import com.mojang.brigadier.suggestion.Suggestions; +import com.mojang.brigadier.suggestion.SuggestionsBuilder; +import com.mojang.brigadier.tree.ArgumentCommandNode; +import com.mojang.brigadier.tree.CommandNode; +import org.jetbrains.annotations.Contract; +import org.jetbrains.annotations.Nullable; +import org.skriptlang.skript.lang.command.CommandCooldown; +import org.skriptlang.skript.lang.command.CommandSourceType; +import org.skriptlang.skript.lang.command.SkriptCommandSender; + +import java.util.Collection; +import java.util.concurrent.CompletableFuture; +import java.util.function.Predicate; + +/** + * SkriptCommandNode implementation of {@link ArgumentSkriptCommandNode}. + * + * @param command sender type + * @see ArgumentSkriptCommandNode.Builder#argument(String, ArgumentType) + */ +public non-sealed class ArgumentSkriptCommandNode extends SkriptCommandNode { + + private final ArgumentCommandNode wrapped; + + public ArgumentSkriptCommandNode(String name, ArgumentType type, @Nullable Command command, + @Nullable Predicate requirement, @Nullable CommandNode redirect, + @Nullable RedirectModifier modifier, boolean forks, @Nullable SuggestionProvider customSuggestions, + @Nullable String permission, @Nullable VariableString permissionMessage, + @Nullable Collection possibleSources, @Nullable CommandCooldown cooldown) { + super(command, requirement, redirect, modifier, forks, permission, permissionMessage, + possibleSources, cooldown); + wrapped = RequiredArgumentBuilder.argument(name, type) + .executes(command) + .requires(requirement) + .forward(redirect, modifier, forks) + .suggests(customSuggestions) + .build(); + } + + public ArgumentType getType() { + return wrapped.getType(); + } + + public @Nullable SuggestionProvider getCustomSuggestions() { + return wrapped.getCustomSuggestions(); + } + + @Override + public ArgumentCommandNode flat() { + RequiredArgumentBuilder builder = RequiredArgumentBuilder.argument(getName(), getType()); + SkriptCommandNode.flat(builder, this); + builder.suggests(getCustomSuggestions()); + return builder.build(); + } + + @Override + protected boolean isValidInput(String s) { + return wrapped.isValidInput(s); + } + + @Override + public String getName() { + return wrapped.getName(); + } + + @Override + public String getUsageText() { + return wrapped.getUsageText(); + } + + @Override + public void parse(StringReader stringReader, CommandContextBuilder commandContextBuilder) throws CommandSyntaxException { + wrapped.parse(stringReader, commandContextBuilder); + } + + @Override + public CompletableFuture listSuggestions(CommandContext commandContext, + SuggestionsBuilder suggestionsBuilder) throws CommandSyntaxException { + return wrapped.listSuggestions(commandContext, suggestionsBuilder); + } + + @Override + public Builder createBuilder() { + return Builder.argument(getName(), getType()) + .executes(getCommand()) + .requires(getRequirement()) + .forward(getRedirect(), getRedirectModifier(), isFork()) + .suggests(getCustomSuggestions()) + .permission(getPermission()) + .permissionMessage(getPermissionMessage()) + .possibleSources(getPossibleSources()) + .cooldown(getCooldown()); + } + + @Override + protected String getSortedKey() { + return getName(); + } + + @Override + public Collection getExamples() { + return wrapped.getExamples(); + } + + /** + * Builder implementation for {@link ArgumentSkriptCommandNode}. + * + * @param command sender type + * @param argument type + */ + public static non-sealed class Builder + extends SkriptCommandNode.Builder> { + + /** + * Creates new builder for argument skript command node with given name and type. + * + * @param name name of the argument + * @param type type of the argument + * @return builder + * @param command sender type + * @param argument type + */ + public static Builder argument(String name, ArgumentType type) { + return new Builder<>(name, type); + } + + private final String name; + private final ArgumentType type; + private @Nullable SuggestionProvider suggestionsProvider; + + protected Builder(String name, ArgumentType type) { + this.name = name; + this.type = type; + } + + /** + * @return argument name + */ + public String getName() { + return this.name; + } + + /** + * @return argument type + */ + public ArgumentType getType() { + return this.type; + } + + /** + * @param provider new suggestion provider for command being built + * @return this + * @see SkriptSuggestionProvider + */ + @Contract("_ -> this") + public Builder suggests(SuggestionProvider provider) { + this.suggestionsProvider = provider; + return getThis(); + } + + /** + * @return suggestion provider for command being built + */ + public @Nullable SuggestionProvider getSuggestionsProvider() { + return suggestionsProvider; + } + + @Override + protected Builder getThis() { + return this; + } + + @Override + public ArgumentSkriptCommandNode build() { + ArgumentSkriptCommandNode result = new ArgumentSkriptCommandNode<>(getName(), getType(), getCommand(), + getRequirement(), getRedirect(), getRedirectModifier(), isFork(), getSuggestionsProvider(), + getPermission(), getPermissionMessage(), getPossibleSources(), getCooldown()); + for (CommandNode argument : getArguments()) + result.addChild(argument); + return result; + } + + } + +} diff --git a/src/main/java/org/skriptlang/skript/brigadier/LiteralSkriptCommandNode.java b/src/main/java/org/skriptlang/skript/brigadier/LiteralSkriptCommandNode.java new file mode 100644 index 00000000000..aaa2b38b4cf --- /dev/null +++ b/src/main/java/org/skriptlang/skript/brigadier/LiteralSkriptCommandNode.java @@ -0,0 +1,159 @@ +package org.skriptlang.skript.brigadier; + +import ch.njol.skript.lang.VariableString; +import com.mojang.brigadier.Command; +import com.mojang.brigadier.RedirectModifier; +import com.mojang.brigadier.StringReader; +import com.mojang.brigadier.builder.LiteralArgumentBuilder; +import com.mojang.brigadier.context.CommandContext; +import com.mojang.brigadier.context.CommandContextBuilder; +import com.mojang.brigadier.exceptions.CommandSyntaxException; +import com.mojang.brigadier.suggestion.Suggestions; +import com.mojang.brigadier.suggestion.SuggestionsBuilder; +import com.mojang.brigadier.tree.CommandNode; +import com.mojang.brigadier.tree.LiteralCommandNode; +import org.jetbrains.annotations.Nullable; +import org.skriptlang.skript.lang.command.CommandCooldown; +import org.skriptlang.skript.lang.command.CommandSourceType; +import org.skriptlang.skript.lang.command.SkriptCommandSender; + +import java.util.Collection; +import java.util.concurrent.CompletableFuture; +import java.util.function.Predicate; + +/** + * SkriptCommandNode implementation of {@link LiteralCommandNode}. + * + * @param command sender type + * @see Builder#literal(String) + */ +public non-sealed class LiteralSkriptCommandNode extends SkriptCommandNode { + + private final LiteralCommandNode wrapped; + + protected LiteralSkriptCommandNode(String literal, @Nullable Command command, @Nullable Predicate requirement, + @Nullable CommandNode redirect, @Nullable RedirectModifier modifier, boolean forks, + @Nullable String permission, @Nullable VariableString permissionMessage, + @Nullable Collection possibleSources, @Nullable CommandCooldown cooldown) { + super(command, requirement, redirect, modifier, forks, permission, permissionMessage, + possibleSources, cooldown); + this.wrapped = LiteralArgumentBuilder.literal(literal) + .executes(command) + .requires(requirement) + .forward(redirect, modifier, forks) + .build(); + } + + /** + * @return literal of this command node + */ + public String getLiteral() { + return wrapped.getLiteral(); + } + + @Override + public LiteralCommandNode flat() { + LiteralArgumentBuilder builder = LiteralArgumentBuilder.literal(getLiteral()); + SkriptCommandNode.flat(builder, this); + return builder.build(); + } + + @Override + protected boolean isValidInput(String s) { + return wrapped.isValidInput(s); + } + + @Override + public String getName() { + return wrapped.getName(); + } + + @Override + public String getUsageText() { + return wrapped.getUsageText(); + } + + @Override + public void parse(StringReader stringReader, + CommandContextBuilder commandContextBuilder) throws CommandSyntaxException { + wrapped.parse(stringReader, commandContextBuilder); + } + + @Override + public CompletableFuture listSuggestions(CommandContext commandContext, + SuggestionsBuilder suggestionsBuilder) { + return wrapped.listSuggestions(commandContext, suggestionsBuilder); + } + + @Override + public Builder createBuilder() { + return Builder.literal(getLiteral()) + .executes(getCommand()) + .requires(getRequirement()) + .forward(getRedirect(), getRedirectModifier(), isFork()) + .permission(getPermission()) + .permissionMessage(getPermissionMessage()) + .possibleSources(getPossibleSources()) + .cooldown(getCooldown()); + } + + @Override + protected String getSortedKey() { + return getLiteral(); + } + + @Override + public Collection getExamples() { + return wrapped.getExamples(); + } + + /** + * Builder implementation for {@link LiteralSkriptCommandNode}. + * + * @param command sender type + */ + public static non-sealed class Builder + extends SkriptCommandNode.Builder> { + + /** + * Creates new builder for literal skript command node with given name. + * + * @param name name of the literal + * @return builder + * @param command sender type + */ + public static Builder literal(String name) { + return new Builder<>(name); + } + + private final String literal; + + protected Builder(String literal) { + this.literal = literal; + } + + /** + * @return literal name + */ + public String getLiteral() { + return literal; + } + + @Override + protected Builder getThis() { + return this; + } + + @Override + public LiteralSkriptCommandNode build() { + LiteralSkriptCommandNode result = new LiteralSkriptCommandNode<>(getLiteral(), getCommand(), + getRequirement(), getRedirect(), getRedirectModifier(), isFork(), getPermission(), + getPermissionMessage(), getPossibleSources(), getCooldown()); + for (CommandNode argument : getArguments()) + result.addChild(argument); + return result; + } + + } + +} diff --git a/src/main/java/org/skriptlang/skript/brigadier/RootSkriptCommandNode.java b/src/main/java/org/skriptlang/skript/brigadier/RootSkriptCommandNode.java new file mode 100644 index 00000000000..2e1f9464531 --- /dev/null +++ b/src/main/java/org/skriptlang/skript/brigadier/RootSkriptCommandNode.java @@ -0,0 +1,193 @@ +package org.skriptlang.skript.brigadier; + +import ch.njol.skript.command.CommandUsage; +import ch.njol.skript.lang.VariableString; +import com.mojang.brigadier.Command; +import com.mojang.brigadier.RedirectModifier; +import com.mojang.brigadier.tree.CommandNode; +import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.Unmodifiable; +import org.skriptlang.skript.lang.command.CommandCooldown; +import org.skriptlang.skript.lang.command.CommandSourceType; +import org.skriptlang.skript.lang.command.SkriptCommandSender; + +import java.util.Collection; +import java.util.LinkedHashSet; +import java.util.Set; +import java.util.function.Predicate; + +/** + * Represents a root node for a Skript command. + *

+ * This is different form {@link com.mojang.brigadier.tree.RootCommandNode}, which is used as a root node + * for a CommandDispatcher. + *

+ * This class just expands on {@link LiteralSkriptCommandNode} with additional properties of a Skript command. + * + * @param command source type + */ +public class RootSkriptCommandNode extends LiteralSkriptCommandNode { + + private final String namespace; + private final @Nullable String description; + private final @Nullable CommandUsage usage; + private final @Unmodifiable Set aliases = new LinkedHashSet<>(); + + protected RootSkriptCommandNode(String namespace, @Nullable String description, @Nullable CommandUsage usage, + @Nullable Collection aliases, String literal, @Nullable Command command, + @Nullable Predicate requirement, @Nullable CommandNode redirect, + @Nullable RedirectModifier modifier, boolean forks, @Nullable String permission, + @Nullable VariableString permissionMessage, @Nullable Collection possibleSources, + @Nullable CommandCooldown cooldown) { + super(literal, command, requirement, redirect, modifier, forks, permission, permissionMessage, + possibleSources, cooldown); + this.namespace = namespace; + this.description = description; + this.usage = usage; + if (aliases != null) + this.aliases.addAll(aliases); + } + + /** + * @return namespace + */ + public String getNamespace() { + return namespace; + } + + /** + * @return description + */ + public @Nullable String getDescription() { + return description; + } + + /** + * @return usage + */ + public @Nullable CommandUsage getUsage() { + return usage; + } + + /** + * @return aliases + */ + public @Unmodifiable Set getAliases() { + return aliases; + } + + public static class Builder extends LiteralSkriptCommandNode.Builder { + + /** + * Creates new builder for root skript command node with given namespace and name. + * + * @param namespace namespace of the root command + * @param name name of the literal + * @return builder + * @param command sender type + */ + public static Builder root(String namespace, + String name) { + return new Builder<>(namespace, name); + } + + private final String namespace; + private @Nullable String description; + private @Nullable CommandUsage usage; + private @Nullable Set aliases = new LinkedHashSet<>(); + + protected Builder(String namespace, String literal) { + super(literal); + this.namespace = namespace; + } + + /** + * @return literal name + */ + public String getNamespace() { + return namespace; + } + + /** + * @param description new description for command being built + * @return this + */ + public Builder description(String description) { + this.description = description; + return this; + } + + /** + * @return description + */ + public @Nullable String getDescription() { + return description; + } + + /** + * @param usage new usage for command being built + * @return this + */ + public Builder usage(CommandUsage usage) { + this.usage = usage; + return this; + } + + /** + * @return usage + */ + public @Nullable CommandUsage getUsage() { + return usage; + } + + /** + * @param aliases new aliases for command being built + * @return this + */ + public Builder aliases(String... aliases) { + if (aliases == null) { + this.aliases = null; + return this; + } + return aliases(Set.of(aliases)); + } + + /** + * @param aliases new aliases for command being built + * @return this + */ + public Builder aliases(@Nullable Collection aliases) { + if (aliases == null) { + this.aliases = null; + return this; + } + this.aliases = new LinkedHashSet<>(aliases); + return this; + } + + /** + * @return aliases + */ + public @Nullable Set getAliases() { + return aliases; + } + + @Override + protected Builder getThis() { + return this; + } + + @Override + public RootSkriptCommandNode build() { + RootSkriptCommandNode result = new RootSkriptCommandNode<>(getNamespace(), + getDescription(), getUsage(), getAliases(), getLiteral(), getCommand(), + getRequirement(), getRedirect(), getRedirectModifier(), isFork(), getPermission(), + getPermissionMessage(), getPossibleSources(), getCooldown()); + for (CommandNode argument : getArguments()) + result.addChild(argument); + return result; + } + + } + +} diff --git a/src/main/java/org/skriptlang/skript/brigadier/SkriptCommandNode.java b/src/main/java/org/skriptlang/skript/brigadier/SkriptCommandNode.java new file mode 100644 index 00000000000..06a03206138 --- /dev/null +++ b/src/main/java/org/skriptlang/skript/brigadier/SkriptCommandNode.java @@ -0,0 +1,290 @@ +package org.skriptlang.skript.brigadier; + +import org.skriptlang.skript.bukkit.command.PaperCommandUtils; +import ch.njol.skript.lang.VariableString; +import com.mojang.brigadier.Command; +import com.mojang.brigadier.RedirectModifier; +import com.mojang.brigadier.builder.ArgumentBuilder; +import com.mojang.brigadier.builder.LiteralArgumentBuilder; +import com.mojang.brigadier.builder.RequiredArgumentBuilder; +import com.mojang.brigadier.tree.ArgumentCommandNode; +import com.mojang.brigadier.tree.CommandNode; +import com.mojang.brigadier.tree.LiteralCommandNode; +import org.jetbrains.annotations.Contract; +import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.Unmodifiable; +import org.skriptlang.skript.lang.command.CommandCooldown; +import org.skriptlang.skript.lang.command.CommandSourceType; +import org.skriptlang.skript.lang.command.SkriptCommandSender; + +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; +import java.util.function.Predicate; + +/** + * Command nodes provided by Skript with additional properties. + * + * @param command source + */ +public sealed abstract class SkriptCommandNode extends CommandNode + permits ArgumentSkriptCommandNode, LiteralSkriptCommandNode { + + private final @Nullable String permission; + private final @Nullable VariableString permissionMessage; + private final Set possibleSources = new HashSet<>(); + private final @Nullable CommandCooldown cooldown; + + protected SkriptCommandNode(@Nullable Command command, @Nullable Predicate requirement, + @Nullable CommandNode redirect, @Nullable RedirectModifier modifier, boolean forks, + @Nullable String permission, @Nullable VariableString permissionMessage, + @Nullable Collection possibleSources, @Nullable CommandCooldown cooldown) { + super(command, requirement, redirect, modifier, forks); + this.permission = permission; + this.permissionMessage = permissionMessage; + if (possibleSources != null) + this.possibleSources.addAll(possibleSources); + this.cooldown = cooldown; + } + + /** + * @return permission required to execute the command + */ + public @Nullable String getPermission() { + return permission; + } + + /** + * @return message if the command source does not have required permissions to execute the command + */ + public @Nullable VariableString getPermissionMessage() { + return permissionMessage; + } + + /** + * @return possible command source types for this command ({@code executable by} entry) + */ + public @Unmodifiable Set getPossibleSources() { + return Collections.unmodifiableSet(possibleSources); + } + + /** + * @return cooldown of this command + */ + public @Nullable CommandCooldown getCooldown() { + return cooldown; + } + + /** + * Creates native brigadier node from this one. + *

+ * Given node must implement the additional properties within its + * executable command or requirements. + *

+ * This is used for transformation of Skript command nodes to regular Brigadier nodes that + * are accepted by platforms such as Paper. + * + * @return native brigadier node + */ + public abstract CommandNode flat(); + + /** + * Creates native brigadier node builder from this node. + *

+ * The conversion is done using {@link #flat()}. All the attributes are then copied + * to the newly created builder instance. + *

+ * This differs from {@link CommandNode#createBuilder()} because it also copies arguments. + * + * @return native brigadier builder + */ + @SuppressWarnings("unchecked") + public final > ArgumentBuilder flatToBuilder() { + return (ArgumentBuilder) flatToBuilder(flat()); + } + + @SuppressWarnings("unchecked") + private static ArgumentBuilder flatToBuilder(CommandNode node) { + ArgumentBuilder builder; + if (node instanceof LiteralCommandNode lcn) { + builder = LiteralArgumentBuilder.literal(lcn.getLiteral()); + } else if (node instanceof ArgumentCommandNode) { + ArgumentCommandNode acn = (ArgumentCommandNode) node; + builder = RequiredArgumentBuilder.argument(acn.getName(), acn.getType()) + .suggests(acn.getCustomSuggestions()); + } else { + throw new IllegalStateException("Unsupported command node type; only native brigadier nodes are supported"); + } + if (node.getRequirement() != null) + builder.requires(node.getRequirement()); + if (node.getRedirect() != null) + builder.forward(node.getRedirect(), node.getRedirectModifier(), node.isFork()); + if (node.getCommand() != null) + builder.executes(node.getCommand()); + for (CommandNode child : node.getChildren()) { + CommandNode nativeNode = child; + if (child instanceof SkriptCommandNode scn) + nativeNode = (CommandNode) scn.flat(); + builder.then(flatToBuilder(nativeNode)); + } + return builder; + } + + /** + * Copies argument node data from given SkriptCommandNode to provided builder and + * provides correct implementation for the data unique to SkriptCommandNodes. + *

+ * This method is used for simplifying the implementation of {@link #flat()}. + * + * @param builder builder to copy the data to + * @param node SkriptCommandNode to copy (as native brigadier node) + * @param command sender type + */ + @SuppressWarnings("unchecked") + static void flat(ArgumentBuilder builder, SkriptCommandNode node) { + builder.requires(sender -> { + if (!node.getRequirement().test(sender)) + return false; + boolean possibleSource = node.getPossibleSources().stream().anyMatch(s -> s.check(null, sender)); + // if there are no specified possible sources, everyone can execute the command + if (!possibleSource && !node.getPossibleSources().isEmpty()) + return false; + String permission = node.getPermission(); + return permission == null || permission.isBlank() || sender.hasPermission(permission); + }); + + CommandNode redirect = node.getRedirect(); + if (redirect instanceof SkriptCommandNode scn) + redirect = (CommandNode) scn.flat(); + builder.forward(redirect, node.getRedirectModifier(), node.isFork()); + + if (node.getCommand() != null) { + builder.executes(context -> { + S sender = context.getSource(); + boolean possibleSource = node.getPossibleSources().stream().anyMatch(s -> s.check(context, sender)); + if (!possibleSource && !node.getPossibleSources().isEmpty()) { + PaperCommandUtils.sendInvalidExecutorMessage(context); + return 0; + } + String permission = node.getPermission(); + if (permission != null && !permission.isBlank() && !sender.hasPermission(permission)) { + PaperCommandUtils.sendPermissionMessage(context, node.getPermissionMessage()); + return 0; + } + if (!node.getRequirement().test(sender)) + return 0; + return node.getCommand().run(context); + }); + } + + if (builder instanceof RequiredArgumentBuilder && node instanceof ArgumentSkriptCommandNode) { + RequiredArgumentBuilder rab = (RequiredArgumentBuilder) builder; + ArgumentSkriptCommandNode argumentNode = (ArgumentSkriptCommandNode) node; + if (argumentNode.getCustomSuggestions() != null) + rab.suggests(argumentNode.getCustomSuggestions()); + } + + for (CommandNode child : node.getChildren()) { + if (child instanceof SkriptCommandNode scn) { + builder.then((CommandNode) scn.flat()); + continue; + } + builder.then(child); + } + } + + /** + * Represents builder of a SkriptCommandNode. + * + * @param command sender type + * @param this builder + */ + public sealed abstract static class Builder> + extends ArgumentBuilder permits ArgumentSkriptCommandNode.Builder, LiteralSkriptCommandNode.Builder { + + private @Nullable String permission = null; + private @Nullable VariableString permissionMessage = null; + private final Set possibleSources = new HashSet<>(); + private @Nullable CommandCooldown cooldown = null; + + /** + * @param permission new permission for command being built + * @return this + */ + @Contract("_ -> this") + public T permission(@Nullable String permission) { + this.permission = permission; + return getThis(); + } + + /** + * @return permission for command being built + */ + public @Nullable String getPermission() { + return permission; + } + + /** + * @param permissionMessage new permission message for command being built + * @return this + */ + @Contract("_ -> this") + public T permissionMessage(@Nullable VariableString permissionMessage) { + this.permissionMessage = permissionMessage; + return getThis(); + } + + /** + * @return permission message for command being built + */ + public @Nullable VariableString getPermissionMessage() { + return permissionMessage; + } + + /** + * @param possibleSources new possible sources for command being built ({@code executable by}) + * @return this + */ + @Contract("_ -> this") + public T possibleSources(Collection possibleSources) { + this.possibleSources.clear(); + this.possibleSources.addAll(possibleSources); + return getThis(); + } + + /** + * @return possible sources for command being built ({@code executable by}) + */ + public @Unmodifiable Set getPossibleSources() { + return Set.copyOf(possibleSources); + } + + /** + * @param cooldown new cooldown for command being built + * @return this + */ + @Contract("_ -> this") + public T cooldown(@Nullable CommandCooldown cooldown) { + this.cooldown = cooldown; + return getThis(); + } + + /** + * @return cooldown for command being built + */ + public @Nullable CommandCooldown getCooldown() { + return cooldown; + } + + /** + * Builds the SkriptCommandNode from this builder. + * + * @return new SkriptCommandNode from this builder + */ + @Contract(pure = true) + public abstract SkriptCommandNode build(); + + } + +} diff --git a/src/main/java/org/skriptlang/skript/brigadier/SkriptSuggestionProvider.java b/src/main/java/org/skriptlang/skript/brigadier/SkriptSuggestionProvider.java new file mode 100644 index 00000000000..6029f1803ba --- /dev/null +++ b/src/main/java/org/skriptlang/skript/brigadier/SkriptSuggestionProvider.java @@ -0,0 +1,69 @@ +package org.skriptlang.skript.brigadier; + +import ch.njol.skript.util.chat.BungeeConverter; +import ch.njol.skript.util.chat.ChatMessages; +import ch.njol.skript.util.chat.MessageComponent; +import com.mojang.brigadier.Message; +import com.mojang.brigadier.context.CommandContext; +import com.mojang.brigadier.exceptions.CommandSyntaxException; +import com.mojang.brigadier.suggestion.SuggestionProvider; +import com.mojang.brigadier.suggestion.Suggestions; +import com.mojang.brigadier.suggestion.SuggestionsBuilder; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.serializer.bungeecord.BungeeComponentSerializer; +import net.kyori.adventure.text.serializer.json.JSONComponentSerializer; +import net.md_5.bungee.api.chat.BaseComponent; +import org.skriptlang.skript.lang.command.SkriptCommandSender; + +import java.util.List; +import java.util.concurrent.CompletableFuture; + +/** + * Implementation of {@link SuggestionProvider} that accepts returning Skript MessageComponents + * instead of Brigadier Messages. + *

+ * Tooltips of those MessageComponents are serialized as JSON and used as tooltips of Brigadier Messages. + * + * @param command source + */ +@FunctionalInterface +public interface SkriptSuggestionProvider extends SuggestionProvider { + + @Override + default CompletableFuture getSuggestions(CommandContext commandContext, + SuggestionsBuilder suggestionsBuilder) throws CommandSyntaxException { + return getSuggestions(commandContext) + .thenApply(components -> { + for (MessageComponent component : components) { + Message tooltip = null; + + if (component.hoverEvent != null + && component.hoverEvent.action == MessageComponent.HoverEvent.Action.show_text) { + //noinspection deprecation + BaseComponent[] baseComponents = BungeeConverter + .convert(ChatMessages.parseToArray(component.hoverEvent.value)); + Component adventure = BungeeComponentSerializer.get().deserialize(baseComponents); + tooltip = () -> JSONComponentSerializer.json().serialize(adventure); + } + // skip any blank components + if (component.text.isBlank()) + continue; + suggestionsBuilder.suggest(component.text, tooltip); + } + return suggestionsBuilder.build(); + }); + } + + /** + * Returns suggestions for given context as Skript MessageComponents. + *

+ * Tooltips of those MessageComponents are mapped to tooltips of the suggestions if + * they are provided as text. + * + * @param commandContext command context + * @return suggestions + */ + CompletableFuture> getSuggestions(CommandContext commandContext) + throws CommandSyntaxException; + +} diff --git a/src/main/java/org/skriptlang/skript/bukkit/command/BrigadierCommandEvent.java b/src/main/java/org/skriptlang/skript/bukkit/command/BrigadierCommandEvent.java new file mode 100644 index 00000000000..2943a7491af --- /dev/null +++ b/src/main/java/org/skriptlang/skript/bukkit/command/BrigadierCommandEvent.java @@ -0,0 +1,58 @@ +package org.skriptlang.skript.bukkit.command; + +import ch.njol.skript.command.CommandEvent; +import com.mojang.brigadier.context.CommandContext; +import org.bukkit.command.CommandSender; +import org.bukkit.event.HandlerList; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.skriptlang.skript.lang.command.SkriptCommandSender; + +/** + * Event for executing brigadier commands. + */ +public class BrigadierCommandEvent extends CommandEvent { + + private final CommandContext context; + + private static @Nullable CommandSender getBukkit(SkriptCommandSender skriptCommandSender) { + return skriptCommandSender instanceof BukkitCommandSender bcs ? bcs.wrapped() : null; + } + + public BrigadierCommandEvent(CommandContext context) { + super(getBukkit(context.getSource()), context.getRootNode().getName(), + context.getNodes().stream() + .map(arg -> arg.getRange().get(context.getInput())) + .toArray(String[]::new)); + this.context = context; + } + + /** + * @return command execution context + */ + public CommandContext getContext() { + return context; + } + + /** + * @deprecated for compatibility reasons with the old command systems + */ + @Override + @Deprecated(forRemoval = true) + public @Nullable CommandSender getSender() { + return super.getSender(); + } + + // Bukkit stuff + private final static HandlerList handlers = new HandlerList(); + + @Override + public @NotNull HandlerList getHandlers() { + return handlers; + } + + public static HandlerList getHandlerList() { + return handlers; + } + +} diff --git a/src/main/java/org/skriptlang/skript/bukkit/command/BrigadierModule.java b/src/main/java/org/skriptlang/skript/bukkit/command/BrigadierModule.java new file mode 100644 index 00000000000..2e5420406b1 --- /dev/null +++ b/src/main/java/org/skriptlang/skript/bukkit/command/BrigadierModule.java @@ -0,0 +1,20 @@ +package org.skriptlang.skript.bukkit.command; + +import org.skriptlang.skript.addon.AddonModule; +import org.skriptlang.skript.addon.SkriptAddon; +import org.skriptlang.skript.bukkit.command.elements.DefaultArgumentTypes; +import org.skriptlang.skript.bukkit.command.elements.StructBrigadierCommand; + +/** + * Module for Brigadier commands. + */ +public class BrigadierModule implements AddonModule { + + @Override + public void load(SkriptAddon addon) { + StructBrigadierCommand.load(addon); + DefaultArgumentTypes.String.load(addon); + DefaultArgumentTypes.Integer.load(addon); + } + +} diff --git a/src/main/java/org/skriptlang/skript/bukkit/command/BrigadierSuggestionsEvent.java b/src/main/java/org/skriptlang/skript/bukkit/command/BrigadierSuggestionsEvent.java new file mode 100644 index 00000000000..67f5a316e8b --- /dev/null +++ b/src/main/java/org/skriptlang/skript/bukkit/command/BrigadierSuggestionsEvent.java @@ -0,0 +1,57 @@ +package org.skriptlang.skript.bukkit.command; + +import com.mojang.brigadier.context.CommandContext; +import org.bukkit.event.HandlerList; +import org.jetbrains.annotations.NotNull; +import org.skriptlang.skript.lang.command.SkriptCommandSender; + +import java.util.ArrayList; +import java.util.List; + +/** + * Event for retrieving command suggestions (tab completions). + */ +public class BrigadierSuggestionsEvent extends BrigadierCommandEvent { + + private final List suggestions = new ArrayList<>(); + + public BrigadierSuggestionsEvent(CommandContext context) { + super(context); + } + + /** + * @return suggestions + */ + public String[] getSuggestions() { + return suggestions.toArray(String[]::new); + } + + /** + * @param suggestions new suggestions + */ + public void setSuggestions(List suggestions) { + this.suggestions.clear(); + if (suggestions != null) + this.suggestions.addAll(suggestions); + } + + /** + * @param suggestions new suggestions + */ + public void setSuggestions(String... suggestions) { + setSuggestions(List.of(suggestions)); + } + + // Bukkit stuff + private final static HandlerList handlers = new HandlerList(); + + @Override + public @NotNull HandlerList getHandlers() { + return handlers; + } + + public static HandlerList getHandlerList() { + return handlers; + } + +} diff --git a/src/main/java/org/skriptlang/skript/bukkit/command/BukkitCommandSender.java b/src/main/java/org/skriptlang/skript/bukkit/command/BukkitCommandSender.java new file mode 100644 index 00000000000..4f5dbd7ac9e --- /dev/null +++ b/src/main/java/org/skriptlang/skript/bukkit/command/BukkitCommandSender.java @@ -0,0 +1,44 @@ +package org.skriptlang.skript.bukkit.command; + +import ch.njol.skript.util.chat.BungeeConverter; +import ch.njol.skript.util.chat.MessageComponent; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Entity; +import org.jetbrains.annotations.Nullable; +import org.skriptlang.skript.lang.command.SkriptCommandSender; + +import java.util.List; +import java.util.UUID; + +/** + * Implementation of {@link SkriptCommandSender} that wraps around + * existing Bukkit {@link CommandSender} instance. + * + * @param wrapped wrapped bukkit command sender + */ +public record BukkitCommandSender(CommandSender wrapped) implements SkriptCommandSender { + + @Override + public void sendMessage(String message) { + wrapped.sendMessage(message); + } + + @Override + @SuppressWarnings("deprecation") + public void sendMessage(List components) { + components.stream().map(BungeeConverter::convert).forEach(wrapped::sendMessage); + } + + @Override + public @Nullable UUID getUniqueID() { + if (wrapped instanceof Entity player) + return player.getUniqueId(); + return null; + } + + @Override + public boolean hasPermission(String permission) { + return wrapped.hasPermission(permission); + } + +} diff --git a/src/main/java/org/skriptlang/skript/bukkit/command/PaperCommandHandler.java b/src/main/java/org/skriptlang/skript/bukkit/command/PaperCommandHandler.java new file mode 100644 index 00000000000..c6e1002a63c --- /dev/null +++ b/src/main/java/org/skriptlang/skript/bukkit/command/PaperCommandHandler.java @@ -0,0 +1,301 @@ +package org.skriptlang.skript.bukkit.command; + +import ch.njol.skript.Skript; +import com.destroystokyo.paper.event.brigadier.AsyncPlayerSendCommandsEvent; +import com.destroystokyo.paper.event.server.AsyncTabCompleteEvent; +import com.mojang.brigadier.CommandDispatcher; +import com.mojang.brigadier.ParseResults; +import com.mojang.brigadier.arguments.ArgumentType; +import com.mojang.brigadier.builder.ArgumentBuilder; +import com.mojang.brigadier.builder.LiteralArgumentBuilder; +import com.mojang.brigadier.builder.RequiredArgumentBuilder; +import com.mojang.brigadier.context.StringRange; +import com.mojang.brigadier.exceptions.CommandSyntaxException; +import com.mojang.brigadier.suggestion.Suggestion; +import com.mojang.brigadier.suggestion.Suggestions; +import com.mojang.brigadier.tree.ArgumentCommandNode; +import com.mojang.brigadier.tree.CommandNode; +import com.mojang.brigadier.tree.LiteralCommandNode; +import com.mojang.brigadier.tree.RootCommandNode; +import io.papermc.paper.command.brigadier.CommandSourceStack; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.serializer.json.JSONComponentSerializer; +import org.bukkit.Bukkit; +import org.bukkit.command.BlockCommandSender; +import org.bukkit.command.Command; +import org.bukkit.command.CommandSender; +import org.bukkit.command.ConsoleCommandSender; +import org.bukkit.entity.Entity; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.Unmodifiable; +import org.jetbrains.annotations.UnmodifiableView; +import org.skriptlang.skript.brigadier.RootSkriptCommandNode; +import org.skriptlang.skript.lang.command.CommandHandler; +import org.skriptlang.skript.lang.command.CommandSourceType; + +import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodHandles; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Consumer; + +/** + * Command Handler implementation for Paper environment. + */ +// TODO thread safety +public class PaperCommandHandler implements CommandHandler, Listener { + + private final Map> commands = new ConcurrentHashMap<>(); + + // It is impossible to properly modify command dispatchers. + // When a new command is (un)registered, the dispatcher tied to this handler + // is set to null and recreated again lazily once it is needed. (when command is executed) + private @Nullable CommandDispatcher dispatcher; + + /** + * Ensures the dispatcher instance is available. + *

+ * If no dispatcher instance is available, a new one is created. + */ + private void ensureDispatcher() { + if (dispatcher != null) return; + dispatcher = new CommandDispatcher<>(); + commands.values().forEach(cmd -> { + ArgumentBuilder node = cmd.flatToBuilder(); + if (!(node instanceof LiteralArgumentBuilder)) // should not happen + return; + //noinspection unchecked + dispatcher.register((LiteralArgumentBuilder) node); + }); + } + + @Override + public boolean registerCommand(RootSkriptCommandNode command) { + if (commands.containsKey(command.getLiteral())) { + Skript.error("Command " + command.getLiteral() + " is already registered"); + return false; + } + commands.put(command.getLiteral(), command); + // TODO proper registration with aliases and help page + Bukkit.getCommandMap().getKnownCommands().put(command.getLiteral(), new WrappedBrigadierCommand(command)); + dispatcher = null; // reset dispatcher + if (Skript.getInstance().isEnabled()) // prevents scheduling task on server shutdown + // command synchronization has to be delayed at least one tick to prevent concurrent modification exception + Bukkit.getScheduler().runTaskLater(Skript.getInstance(), PaperCommandUtils::syncCommands, 1); + return true; + } + + @Override + public boolean unregisterCommand(RootSkriptCommandNode command) { + boolean result = commands.remove(command.getLiteral(), command); + if (!result) + return false; + Bukkit.getCommandMap().getKnownCommands().remove(command.getLiteral()); + dispatcher = null; // reset dispatcher + if (Skript.getInstance().isEnabled()) // prevents scheduling task on server shutdown + // command synchronization has to be delayed at least one tick to prevent concurrent modification exception + Bukkit.getScheduler().runTaskLater(Skript.getInstance(), PaperCommandUtils::syncCommands, 1); + return true; + } + + @Override + public @Nullable RootSkriptCommandNode getCommand(String label) { + return commands.get(label); + } + + @Override + public @UnmodifiableView Map> getAllCommands() { + return Collections.unmodifiableMap(commands); + } + + @Override + public boolean dispatchCommand(BukkitCommandSender source, String input) { + ensureDispatcher(); + assert dispatcher != null; + // TODO + // if the command does not exist return false + // send error message to source similar to vanilla + try { + dispatcher.execute(input, source); + } catch (CommandSyntaxException exception) { + Skript.getInstance().getSLF4JLogger().info("Command syntax error", exception); + } + return true; + } + + @Override + public @Unmodifiable Set supportedTypes() { + return Set.of( + CommandSourceType.simple(BukkitCommandSender.class, sender -> sender.wrapped() instanceof Player, + "player", "the player", "players", "the players"), + CommandSourceType.simple(BukkitCommandSender.class, + sender -> sender.wrapped() instanceof ConsoleCommandSender, + "console", "the console", "server", "the server"), + CommandSourceType.simple(BukkitCommandSender.class, + sender -> sender.wrapped() instanceof BlockCommandSender, + "block", "blocks", "command block", "command blocks"), + CommandSourceType.simple(BukkitCommandSender.class, sender -> sender.wrapped() instanceof Entity, + "entity", "entities") + ); + } + + // We access the node children here to remove the greedy string node added by Bukkit. + // This results only in a visual change on the client - the Bukkit greedy string argument node + // for all Bukkit commands is not sent to the client. + private static final @Nullable MethodHandle NODE_CHILDREN_GETTER; + + static { + MethodHandle nodeChildrenGetter = null; + try { + nodeChildrenGetter = MethodHandles.privateLookupIn(CommandNode.class, MethodHandles.lookup()) + .unreflectGetter(CommandNode.class.getDeclaredField("children")); + } catch (Exception exception) { + if (Skript.debug()) + throw Skript.exception(exception, "Failed to access the command node children field"); + } + NODE_CHILDREN_GETTER = nodeChildrenGetter; + } + + @EventHandler + @SuppressWarnings("UnstableApiUsage") + private void onAsyncPlayerSendCommands(AsyncPlayerSendCommandsEvent<@NotNull CommandSourceStack> event) + throws Throwable { + // This event will potentially (and most likely) fire twice. Once for async, and once again for sync. + if (!event.isAsynchronous() && event.hasFiredAsync()) + return; + RootCommandNode rootNode = event.getCommandNode(); + Consumer> bukkitNodeRemover = node -> {}; + + // if the handle is available, we remove the node added by Bukkit command system. + if (NODE_CHILDREN_GETTER != null) { + //noinspection unchecked + Map> children = (Map>) + NODE_CHILDREN_GETTER.invokeExact((CommandNode) rootNode); + bukkitNodeRemover = node -> children.remove(node.getName()); + } + + // and we add our nodes with the correct command structure + for (RootSkriptCommandNode node : commands.values()) { + CommandNode paperCompatible = convertToClientsidePaper(node.flat()); + bukkitNodeRemover.accept(paperCompatible); + rootNode.addChild(paperCompatible); + } + } + + @EventHandler(priority = EventPriority.MONITOR) + private void onAsyncTabComplete(AsyncTabCompleteEvent event) throws Throwable { + String buffer = event.getBuffer(); + String label = getLabel(buffer); + if (getCommand(label) == null) + return; + + ensureDispatcher(); + assert dispatcher != null; + + ParseResults parseResults = + dispatcher.parse(buffer.substring(1), new BukkitCommandSender(event.getSender())); + Suggestions suggestions = dispatcher.getCompletionSuggestions(parseResults).get(); + StringRange range = suggestions.getRange(); + + List completions = new LinkedList<>(); + for (Suggestion suggestion : suggestions.getList()) { + @Nullable Component tooltip = null; + if (suggestion.getTooltip() != null) { + try { + // we expect JSON component format from SkriptSuggestionProvider + tooltip = JSONComponentSerializer.json().deserialize(suggestion.getTooltip().getString()); + } catch (Exception exception) { + // unexpected format, we still provide the tooltip as raw string + tooltip = Component.text(suggestion.getTooltip().getString()); + } + } + String text = suggestion.getText(); + + // On console side, the command is still a Bukkit command - has only a single greedy string node. + // We need to adjust the completions to that; we also suggest the previous arguments from the buffer. + if (event.getSender() instanceof ConsoleCommandSender) { + // There is already a text present for the argument we suggest for + if (range.getLength() != 0) { + String[] args = buffer.split(" "); + // We filter out suggestions that do not start with the last argument in the buffer. + // We can not properly use brigadier string range for console so the suggestions get appended + // to the buffer. + if (!text.startsWith(args[args.length - 1])) + continue; + } + // the suggestion also contains all previous arguments in the buffer + text = buffer.substring(1 /* slash */ + label.length() + 1 /* space */) + // we cut off the start of the suggestion already in the buffer if present + + text.substring(range.getLength()); + } + + completions.add(AsyncTabCompleteEvent.Completion.completion(text, tooltip)); + } + event.completions(completions); + event.setHandled(true); + } + + private static String getLabel(String buffer) { + String label = buffer.split(" ")[0]; + if (label.startsWith("/")) + return label.substring(1); + return label; + } + + /** + * Converts given command node to a node that can be safely sent to the client. + * + * @param node node to convert + * @return node that can be sent to the client + */ + // TODO convert paper argument types to NMS argument types + // TODO filter out commands with requirements player does not meet (mirrors vanilla behaviour) + private CommandNode convertToClientsidePaper(CommandNode node) { + ArgumentBuilder builder; + if (node instanceof LiteralCommandNode lcn) { + builder = LiteralArgumentBuilder.literal(lcn.getLiteral()); + } else if (node instanceof ArgumentCommandNode acn) { + //noinspection unchecked + builder = RequiredArgumentBuilder.argument(acn.getName(), + (ArgumentType) acn.getType()) + // We provide dummy suggestions for all argument command nodes. + // Such nodes are then marked as 'Has suggestions type' on the client and + // always asked for tab completions, handled by the listener in this class. + .suggests((ctx, b) -> Suggestions.empty()); + } else { + throw new IllegalArgumentException("Unsupported node implementation; only native Brigadier nodes " + + "are supported"); + } + if (node.getRequirement() != null) + builder.requires(stack -> true); + if (node.getRedirect() != null) + builder.forward(convertToClientsidePaper(node.getRedirect()), + ctx -> Collections.emptyList(), node.isFork()); + if (node.getCommand() != null) + builder.executes(ctx -> com.mojang.brigadier.Command.SINGLE_SUCCESS); + node.getChildren().forEach(child -> builder.then(convertToClientsidePaper(child))); + return builder.build(); + } + + private class WrappedBrigadierCommand extends Command { + + protected WrappedBrigadierCommand(RootSkriptCommandNode node) { + super(node.getName(), node.getDescription() != null ? node.getDescription() : "", + "" /* TODO usage */, new ArrayList<>(node.getAliases())); + // TODO permission, permission message + } + + @Override + public boolean execute(@NotNull CommandSender sender, @NotNull String commandLabel, @NotNull String @NotNull [] args) { + return PaperCommandHandler.this.dispatchCommand(new BukkitCommandSender(sender), + (getName() + " " + String.join(" ", args)).trim()); + } + + } + +} diff --git a/src/main/java/org/skriptlang/skript/bukkit/command/PaperCommandUtils.java b/src/main/java/org/skriptlang/skript/bukkit/command/PaperCommandUtils.java new file mode 100644 index 00000000000..c4a3a24b5fc --- /dev/null +++ b/src/main/java/org/skriptlang/skript/bukkit/command/PaperCommandUtils.java @@ -0,0 +1,90 @@ +package org.skriptlang.skript.bukkit.command; + +import ch.njol.skript.Skript; +import ch.njol.skript.lang.VariableString; +import ch.njol.skript.localization.Language; +import ch.njol.skript.localization.Message; +import com.mojang.brigadier.context.CommandContext; +import org.bukkit.Bukkit; +import org.bukkit.Server; +import org.jetbrains.annotations.Nullable; +import org.skriptlang.skript.lang.command.SkriptCommandSender; + +import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodHandles; +import java.lang.reflect.Method; + +/** + * Utilities related to Skript commands on Paper platform. + */ +public final class PaperCommandUtils { + + private PaperCommandUtils() { + throw new UnsupportedOperationException(); + } + + /** + * Sends permission message to the command source of given context. + * + * @param context context + * @param permissionMessage permission message to send + * @param command source + */ + @SuppressWarnings("unchecked") + public static void sendPermissionMessage(CommandContext context, + VariableString permissionMessage) { + if (permissionMessage == null) + permissionMessage = VariableString.newInstance(Language.get("commands.no permission message")); + assert permissionMessage != null; + BrigadierCommandEvent event = new BrigadierCommandEvent((CommandContext) context); + String formatted = permissionMessage.getSingle(event); + if (formatted != null) + context.getSource().sendMessage(formatted); + } + + // TODO missing in the lang file + private static final Message INVALID_EXECUTOR_MESSAGE = new Message("commands.invalid executor"); + + /** + * Sends message about unsupported command executor type to the command source of given context. + * + * @param context context + * @param command source + */ + public static void sendInvalidExecutorMessage(CommandContext context) { + context.getSource().sendMessage(INVALID_EXECUTOR_MESSAGE.toString()); + } + + + private static final @Nullable MethodHandle SYNC_COMMANDS_HANDLE; + + static { + MethodHandle handle = null; + try { + Server server = Bukkit.getServer(); + Method methodInstance = server.getClass().getDeclaredMethod("syncCommands"); + methodInstance.setAccessible(true); + handle = MethodHandles.privateLookupIn(server.getClass(), MethodHandles.lookup()) + .unreflect(methodInstance); + handle = handle.bindTo(server); + } catch (Exception exception) { + // Ignore except for debugging. This is not necessary or in any way supported functionality + if (Skript.debug()) + throw Skript.exception(exception, "Failed to access the syncCommands method"); + } + SYNC_COMMANDS_HANDLE = handle; + } + + /** + * Synchronizes the server commands with the client. + */ + public static void syncCommands() { + if (SYNC_COMMANDS_HANDLE == null) return; + try { + SYNC_COMMANDS_HANDLE.invokeExact(); + } catch (Throwable exception) { + throw Skript.exception(exception, "Failed to invoke the syncCommands method"); + } + } + +} diff --git a/src/main/java/org/skriptlang/skript/bukkit/command/elements/DefaultArgumentTypes.java b/src/main/java/org/skriptlang/skript/bukkit/command/elements/DefaultArgumentTypes.java new file mode 100644 index 00000000000..3212f14a8bd --- /dev/null +++ b/src/main/java/org/skriptlang/skript/bukkit/command/elements/DefaultArgumentTypes.java @@ -0,0 +1,114 @@ +package org.skriptlang.skript.bukkit.command.elements; + +import ch.njol.skript.lang.Expression; +import ch.njol.skript.lang.SkriptParser; +import ch.njol.skript.lang.util.ContextlessEvent; +import com.mojang.brigadier.arguments.ArgumentType; +import com.mojang.brigadier.arguments.IntegerArgumentType; +import com.mojang.brigadier.arguments.StringArgumentType; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.skriptlang.skript.addon.SkriptAddon; +import org.skriptlang.skript.lang.command.ArgumentTypeElement; +import org.skriptlang.skript.registration.SyntaxInfo; +import org.skriptlang.skript.registration.SyntaxOrigin; + +/** + * Default Argument Types provided by Skript. + */ +public class DefaultArgumentTypes { + + private DefaultArgumentTypes() { + throw new UnsupportedOperationException(); + } + + /** + * String argument type that provides 3 different implementations. + * 1. single word + * 2. quoted string + * 3. greedy string + */ + public static final class String extends ArgumentTypeElement.Simple { + + private String() { + super("string"); + } + + public static void load(SkriptAddon addon) { + addon.syntaxRegistry().register(ArgumentTypeElement.REGISTRY_KEY, + SyntaxInfo.builder(DefaultArgumentTypes.String.class) + .addPatterns("[single] (word|string|text)", + "(quotable|quoted) (string|text)", + "greedy (string|text)") + .supplier(DefaultArgumentTypes.String::new) + .origin(SyntaxOrigin.of(addon)) + .build()); + } + + @Override + protected @Nullable ArgumentType get(Expression[] expressions, int matchedPattern, + SkriptParser.ParseResult parseResult) { + return switch (matchedPattern) { + case 0 -> StringArgumentType.word(); + case 1 -> StringArgumentType.string(); + case 2 -> StringArgumentType.greedyString(); + default -> null; + }; + } + + } + + /** + * Integer argument type. + */ + public static final class Integer extends ArgumentTypeElement.Simple { + + private Integer() { + super("integer"); + } + + public static void load(SkriptAddon addon) { + addon.syntaxRegistry().register(ArgumentTypeElement.REGISTRY_KEY, + SyntaxInfo.builder(DefaultArgumentTypes.Integer.class) + .addPatterns("integer", + "positive integer", + "integer greater than [equal:or equal to] %integer%", + "integer less than [equal:or equal to] %integer%", + "integer between %integer% and %integer%") + .supplier(DefaultArgumentTypes.Integer::new) + .origin(SyntaxOrigin.of(addon)) + .build()); + } + + @Override + protected @NotNull ArgumentType get(Expression[] expressions, int matchedPattern, + SkriptParser.ParseResult parseResult) { + int min = java.lang.Integer.MIN_VALUE; + int max = java.lang.Integer.MAX_VALUE; + + switch (matchedPattern) { + // case 0 is regular integer + case 1 -> min = 0; + case 2 -> { + min = (int) expressions[0].getSingle(ContextlessEvent.get()); + if (!parseResult.hasTag("equal")) + min++; + } + case 3 -> { + max = (int) expressions[0].getSingle(ContextlessEvent.get()); + if (!parseResult.hasTag("equal")) + max--; + } + case 4 -> { + int first = (int) expressions[0].getSingle(ContextlessEvent.get()); + int second = (int) expressions[1].getSingle(ContextlessEvent.get()); + min = Math.min(first, second); + max = Math.max(first, second); + } + } + return IntegerArgumentType.integer(min, max); + } + + } + +} diff --git a/src/main/java/org/skriptlang/skript/bukkit/command/elements/StructBrigadierCommand.java b/src/main/java/org/skriptlang/skript/bukkit/command/elements/StructBrigadierCommand.java new file mode 100644 index 00000000000..ea81d41688e --- /dev/null +++ b/src/main/java/org/skriptlang/skript/bukkit/command/elements/StructBrigadierCommand.java @@ -0,0 +1,42 @@ +package org.skriptlang.skript.bukkit.command.elements; + +import ch.njol.skript.Skript; +import org.bukkit.Bukkit; +import org.bukkit.event.Event; +import org.jetbrains.annotations.Nullable; +import org.skriptlang.skript.addon.SkriptAddon; +import org.skriptlang.skript.bukkit.command.PaperCommandHandler; +import org.skriptlang.skript.lang.command.CommandHandler; +import org.skriptlang.skript.lang.command.SkriptCommandSender; +import org.skriptlang.skript.lang.command.StructGeneralCommand; + +/** + * Brigadier command structure implementation. + */ +public class StructBrigadierCommand extends StructGeneralCommand { + + private static final PaperCommandHandler HANDLER = new PaperCommandHandler(); + + /** + * Registers the syntax for the brigadier commands for Paper platform and registers + * the necessary event handlers for its command handler. + * + * @param addon addon to register the structure for + */ + public static void load(SkriptAddon addon) { + StructGeneralCommand.registerCommandStructure(addon, StructBrigadierCommand.class, "brigadier"); + Bukkit.getPluginManager().registerEvents(HANDLER, Skript.getInstance()); + } + + @Override + @SuppressWarnings("unchecked") + public CommandHandler getHandler() { + return (CommandHandler) (CommandHandler) HANDLER; + } + + @Override + public String toString(@Nullable Event event, boolean debug) { + return "brigadier command /" + commandNode.getNamespace() + ":" + commandNode.getLiteral(); + } + +} diff --git a/src/main/java/org/skriptlang/skript/lang/command/ArgumentTypeElement.java b/src/main/java/org/skriptlang/skript/lang/command/ArgumentTypeElement.java new file mode 100644 index 00000000000..fcfec3e529e --- /dev/null +++ b/src/main/java/org/skriptlang/skript/lang/command/ArgumentTypeElement.java @@ -0,0 +1,112 @@ +package org.skriptlang.skript.lang.command; + +import ch.njol.skript.lang.Expression; +import ch.njol.skript.lang.SkriptParser; +import ch.njol.skript.lang.SyntaxElement; +import ch.njol.util.Kleenean; +import com.mojang.brigadier.arguments.ArgumentType; +import io.papermc.paper.command.brigadier.argument.CustomArgumentType; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.skriptlang.skript.registration.SyntaxInfo; +import org.skriptlang.skript.registration.SyntaxRegistry; + +/** + * Represents a custom argument type ranging from simple types to more complex structures + * like {@code integer between 1 and 5}. + *

+ * Implementations of this class are responsible for converting their Skript-specific + * argument definition into a Brigadier {@link ArgumentType}. + * + * @param the type that the Brigadier {@link ArgumentType} will parse the argument into + * + * @see Custom + */ +public abstract class ArgumentTypeElement implements SyntaxElement { + + /** + * Registry key for the argument types. + */ + public static final SyntaxRegistry.Key>> REGISTRY_KEY + = SyntaxRegistry.Key.of("argument type"); + + /** + * Returns the Brigadier {@link ArgumentType} that corresponds to this argument type element. + * + * @apiNote If this argument type element represents a custom type not supported + * by Paper, and is meant to be used within Paper environment, it is required to provide an + * appropriate {@link io.papermc.paper.command.brigadier.argument.CustomArgumentType} + * implementation. + * + * @return a {@link ArgumentType} instance representing this argument type element + */ + public abstract ArgumentType toBrigadier(); + + /** + * Returns a short codename that is used as a placeholder for unnamed arguments of this type. + * + * @return code name for this argument type + */ + public abstract String codeName(); + + @Override + public @NotNull String getSyntaxTypeName() { + return "argument type"; + } + + /** + * Custom argument type that also implements {@link CustomArgumentType}. + *

+ * This class serves as a convenient base for implementing custom argument types + * available within Paper environment. + * + * @param the type that the Brigadier {@link ArgumentType} will parse the argument into + * @param the native argument type supported by Paper + */ + public abstract static class Custom extends ArgumentTypeElement implements CustomArgumentType { + + @Override + public ArgumentType toBrigadier() { + return this; + } + + } + + /** + * Convenient implementation of ArgumentTypeElement that makes it simple to wrap around + * existing argument types. + * + * @param the type that the Brigadier {@link ArgumentType} will parse the argument into + */ + public abstract static class Simple extends ArgumentTypeElement { + + private final String codeName; + private ArgumentType nativeType; + + protected Simple(String codeName) { + this.codeName = codeName; + } + + protected abstract @Nullable ArgumentType get(Expression[] expressions, int matchedPattern, + SkriptParser.ParseResult parseResult); + + @Override + public ArgumentType toBrigadier() { + return nativeType; + } + + @Override + public boolean init(Expression[] expressions, int matchedPattern, Kleenean isDelayed, + SkriptParser.ParseResult parseResult) { + nativeType = get(expressions, matchedPattern, parseResult); + return nativeType != null; + } + + @Override + public String codeName() { + return codeName; + } + + } + +} diff --git a/src/main/java/org/skriptlang/skript/lang/command/CommandArgument.java b/src/main/java/org/skriptlang/skript/lang/command/CommandArgument.java new file mode 100644 index 00000000000..f176bcaaa10 --- /dev/null +++ b/src/main/java/org/skriptlang/skript/lang/command/CommandArgument.java @@ -0,0 +1,91 @@ +package org.skriptlang.skript.lang.command; + +import com.mojang.brigadier.arguments.ArgumentType; +import org.jetbrains.annotations.Unmodifiable; +import org.skriptlang.skript.brigadier.ArgumentSkriptCommandNode; +import org.skriptlang.skript.brigadier.LiteralSkriptCommandNode; +import org.skriptlang.skript.brigadier.RootSkriptCommandNode; +import org.skriptlang.skript.brigadier.SkriptCommandNode; + +import java.util.Collections; +import java.util.List; + +/** + * Represents a parsed command argument (from Skript code). + *

+ * In case of literal command arguments, single argument can represent multiple command + * nodes, e.g. {@code (req|request)} is a single command argument but must be represented + * by two literal command nodes, 'req' and 'request'. + */ +// TODO optional, possibly plural +public sealed interface CommandArgument { + + /** + * Creates an empty builders from given command argument. + *

+ * In case of literal command arguments, single argument can represent multiple command + * nodes, e.g. {@code (req|request)} is a single command argument but must be represented + * by two literal command nodes, 'req' and 'request'. + * + * @return empty builders for this command argument + */ + List> emptyBuilders(); + + /** + * Represents a literal or a group of literals. + * + * @param literals literals + */ + record Literal(@Unmodifiable List literals) implements CommandArgument { + + public Literal(String... literals) { + this(List.of(literals)); + } + + public Literal { + literals = Collections.unmodifiableList(literals); + } + + @Override + public List> emptyBuilders() { + return literals.stream().map(LiteralSkriptCommandNode.Builder::literal).toList(); + } + + } + + /** + * Represents a typed argument parsed from {@link ArgumentTypeElement}. + * + * @param name name of the argument + * @param type argument type + * @param argument type + */ + record Typed(String name, ArgumentType type) implements CommandArgument { + + public Typed(String name, ArgumentTypeElement typeElement) { + this(name, typeElement.toBrigadier()); + } + + @Override + public List> emptyBuilders() { + return Collections.singletonList(ArgumentSkriptCommandNode.Builder.argument(name, type)); + } + + } + + /** + * Represents a root command node. + * + * @param label label + * @see org.skriptlang.skript.brigadier.RootSkriptCommandNode + */ + record Root(String namespace, String label) implements CommandArgument { + + @Override + public List> emptyBuilders() { + return Collections.singletonList(RootSkriptCommandNode.Builder.root(namespace, label)); + } + + } + +} diff --git a/src/main/java/org/skriptlang/skript/lang/command/CommandArgumentParser.java b/src/main/java/org/skriptlang/skript/lang/command/CommandArgumentParser.java new file mode 100644 index 00000000000..dc3054a75c0 --- /dev/null +++ b/src/main/java/org/skriptlang/skript/lang/command/CommandArgumentParser.java @@ -0,0 +1,127 @@ +package org.skriptlang.skript.lang.command; + +import ch.njol.skript.Skript; +import ch.njol.skript.lang.ParseContext; +import ch.njol.skript.lang.SkriptParser; +import com.google.common.base.Preconditions; +import org.skriptlang.skript.registration.SyntaxInfo; + +import java.util.*; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * A utility class responsible for parsing command argument strings. + *

+ * It tokenizes an input string into a list of {@link CommandArgument} objects. + */ +public final class CommandArgumentParser { + + private CommandArgumentParser() { + throw new UnsupportedOperationException(); + } + + private static final Pattern ARGUMENT_TOKENIZER_PATTERN = + Pattern.compile("<[^>]+>|\\([^)]+\\)|\\S+"); + + private static final Pattern TYPED_ARGUMENT_PARSER_PATTERN = + Pattern.compile("\\s*(?:([^:]+?):)?\\s*(.+?)\\s*"); + + private static final Pattern LITERAL_GROUP_PATTERN = + Pattern.compile("\\(([^)]+)\\)"); + + private static final ThreadLocal> ARG_COUNTERS = ThreadLocal.withInitial(HashMap::new); + + /** + * Resets the argument counter for current command. + *

+ * It is used for auto-generating argument names for unnamed arguments. + */ + static void resetArgumentCounter() { + ARG_COUNTERS.set(new HashMap<>()); + } + + /** + * Parses a raw string of arguments into a list of CommandArgument objects. + * + * @param arguments the raw argument string (e.g. "help ") + * @return a list of parsed command arguments + */ + public static List parse(String arguments) { + if (arguments == null || arguments.isBlank()) + return Collections.emptyList(); + + Matcher matcher = ARGUMENT_TOKENIZER_PATTERN.matcher(arguments); + List argumentList = new LinkedList<>(); + + while (matcher.find()) { + String token = matcher.group(); + try { + argumentList.add(parseToken(token)); + } catch (IllegalArgumentException e) { + Skript.error("Failed to parse command argument '" + token + "': " + e.getMessage()); + // Stop parsing on the first error. + return Collections.emptyList(); + } + } + return argumentList; + } + + /** + * Parses a single argument token into a {@link CommandArgument} object. + * + * @param token a single token (e.g. "", "help", or "(first|second)"). + * @return The parsed CommandArgument + */ + private static CommandArgument parseToken(String token) { + if (token.startsWith("<") && token.endsWith(">")) { + return parseTypedArgument(token); + } else if (token.startsWith("(") && token.endsWith(")")) { + Matcher matcher = LITERAL_GROUP_PATTERN.matcher(token); + Preconditions.checkArgument(matcher.matches(), "Invalid literal group format: must be " + + "'(literal1|literal2)', but got '" + token + "'"); + String content = matcher.group(1); + List literals = Arrays.asList(content.split("\\|")); + Preconditions.checkArgument(!literals.isEmpty() && literals.stream().noneMatch(String::isBlank), + "Literal group cannot contain blank or empty literals in '" + token + "'"); + return new CommandArgument.Literal(literals); + } else { + return new CommandArgument.Literal(token); + } + } + + @SuppressWarnings("unchecked") + private static CommandArgument.Typed parseTypedArgument(String token) { + // remove the outer brackets to get the content + String content = token.substring(1, token.length() - 1); + Matcher typedMatcher = TYPED_ARGUMENT_PARSER_PATTERN.matcher(content); + Preconditions.checkArgument(typedMatcher.matches(), "Invalid format: must be '' or " + + "'', but got '" + token + "'"); + String name = typedMatcher.group(1); + String typeExpression = typedMatcher.group(2); + + if (name != null) { + Preconditions.checkArgument(!name.isBlank(), "Argument name cannot be blank in '" + token + + "'"); + } + Preconditions.checkArgument(!typeExpression.isBlank(), "Argument type cannot be blank in '" + + token + "'"); + + var argumentTypes = (Collection>>) (Collection) + Skript.instance().syntaxRegistry().syntaxes(ArgumentTypeElement.REGISTRY_KEY); + ArgumentTypeElement parsedType = SkriptParser.parseStatic(typeExpression, argumentTypes.iterator(), + ParseContext.DEFAULT, "Failed to parse argument type '" + typeExpression + "'"); + Preconditions.checkArgument(parsedType != null, "Unknown argument type in '" + + token + "'"); + + // create placeholder name for unnamed arguments + if (name == null) { + name = parsedType.codeName() + + ARG_COUNTERS.get().computeIfAbsent(parsedType.codeName(), n -> new AtomicInteger()).getAndIncrement(); + } + + return new CommandArgument.Typed<>(name, parsedType); + } + +} diff --git a/src/main/java/org/skriptlang/skript/lang/command/CommandCooldown.java b/src/main/java/org/skriptlang/skript/lang/command/CommandCooldown.java new file mode 100644 index 00000000000..c63d6d75ca6 --- /dev/null +++ b/src/main/java/org/skriptlang/skript/lang/command/CommandCooldown.java @@ -0,0 +1,5 @@ +package org.skriptlang.skript.lang.command; + +// TODO +public class CommandCooldown { +} diff --git a/src/main/java/org/skriptlang/skript/lang/command/CommandHandler.java b/src/main/java/org/skriptlang/skript/lang/command/CommandHandler.java new file mode 100644 index 00000000000..5a2298db77b --- /dev/null +++ b/src/main/java/org/skriptlang/skript/lang/command/CommandHandler.java @@ -0,0 +1,69 @@ +package org.skriptlang.skript.lang.command; + +import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.Unmodifiable; +import org.jetbrains.annotations.UnmodifiableView; +import org.skriptlang.skript.brigadier.RootSkriptCommandNode; + +import java.util.Map; +import java.util.Set; + +/** + * Handles the registration, un-registration and dispatching of commands. + * + * @param command source + */ +public interface CommandHandler { + + /** + * Registers a new command. + * + * @param command command to register + * @return if the command was registered + */ + boolean registerCommand(RootSkriptCommandNode command); + + /** + * Unregisters a command. + * + * @param command command to unregister + * @return if the command was unregistered + */ + boolean unregisterCommand(RootSkriptCommandNode command); + + /** + * Returns command of given label or alias. + * + * @param label command label or alias + * @return command with given label if it exists + */ + @Nullable RootSkriptCommandNode getCommand(String label); + + /** + * Returns all registered commands. + * + * @return all registered commands + */ + @UnmodifiableView Map> getAllCommands(); + + /** + * Dispatches a command from a given source. + * This method is responsible for parsing and executing the command logic. + * + * @param source source initiating the command + * @return true if the command dispatch was handled (not necessarily successful execution, but + * the handler processed it), false otherwise + */ + boolean dispatchCommand(S source, String input); + + /** + * Returns supported command source types. + *

+ * These types are used in the {@code executable by} entry in command structures. + * + * @return supported command source types + */ + @Unmodifiable Set supportedTypes(); + +} + diff --git a/src/main/java/org/skriptlang/skript/lang/command/CommandSourceType.java b/src/main/java/org/skriptlang/skript/lang/command/CommandSourceType.java new file mode 100644 index 00000000000..0b7370b9645 --- /dev/null +++ b/src/main/java/org/skriptlang/skript/lang/command/CommandSourceType.java @@ -0,0 +1,69 @@ +package org.skriptlang.skript.lang.command; + +import com.mojang.brigadier.context.CommandContext; +import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.Unmodifiable; + +import java.util.Set; +import java.util.function.BiFunction; +import java.util.function.Function; + +/** + * Represents a type of command source, e.g. console. + * + * @param type class representing the command source + * @param names names of the command source, this is used by the {@code executable by} entry + * @param checkFunction function to check if the source type can execute command in given context + */ +public record CommandSourceType(Class type, + @Unmodifiable Set names, + BiFunction<@Nullable CommandContext, SkriptCommandSender, Boolean> checkFunction) { + + public CommandSourceType { + names = Set.copyOf(names); + } + + /** + * Simple command source type that just checks if the + * sender is instance of the CommandSourceType type. + * + * @param type lass representing the command source + * @param names names of the command source, this is used by the {@code executable by} entry + * @return command source type instance + */ + public static CommandSourceType typed(Class type, String... names) { + return new CommandSourceType(type, Set.of(names), ((context, sender) -> type.isInstance(sender))); + } + + /** + * Simple command source type that applies check function on instance of specific command sender implementation. + * + * @param sourceType expected skript command sender type + * @param checkFunction check function + * @param names names of the command source, this is used by the {@code executable by} entry + * @return command source type instance + * @param expected skript command sender type + */ + public static CommandSourceType simple(Class sourceType, + Function checkFunction, String... names) { + return new CommandSourceType(SkriptCommandSender.class, Set.of(names), + (ctx, sender) -> { + if (!sourceType.isInstance(sender)) return false; + return checkFunction.apply(sourceType.cast(sender)); + } + ); + } + + /** + * Checks whether given sender is instance of this source type. + * + * @param sender sender to check + * @return if the sender is of this type + * @param command sender type + */ + @SuppressWarnings("unchecked") + public boolean check(@Nullable CommandContext context, S sender) { + return checkFunction.apply((CommandContext) context, sender); + } + +} diff --git a/src/main/java/org/skriptlang/skript/lang/command/CommandUtils.java b/src/main/java/org/skriptlang/skript/lang/command/CommandUtils.java new file mode 100644 index 00000000000..2a0d706d923 --- /dev/null +++ b/src/main/java/org/skriptlang/skript/lang/command/CommandUtils.java @@ -0,0 +1,309 @@ +package org.skriptlang.skript.lang.command; + +import ch.njol.skript.ScriptLoader; +import ch.njol.skript.Skript; +import ch.njol.skript.command.CommandUsage; +import com.google.common.collect.HashBasedTable; +import com.google.common.collect.Table; +import org.skriptlang.skript.brigadier.SkriptSuggestionProvider; +import org.skriptlang.skript.bukkit.command.BrigadierCommandEvent; +import org.skriptlang.skript.bukkit.command.BrigadierSuggestionsEvent; +import ch.njol.skript.config.SectionNode; +import ch.njol.skript.lang.*; +import ch.njol.skript.lang.parser.ParserInstance; +import ch.njol.skript.lang.util.SimpleEvent; +import ch.njol.skript.util.chat.ChatMessages; +import com.google.common.base.Preconditions; +import org.bukkit.Bukkit; +import org.bukkit.event.Event; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.skriptlang.skript.brigadier.ArgumentSkriptCommandNode; +import org.skriptlang.skript.brigadier.RootSkriptCommandNode; +import org.skriptlang.skript.brigadier.SkriptCommandNode; +import org.skriptlang.skript.lang.entry.EntryContainer; + +import java.util.*; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; + +public final class CommandUtils { + + /** + * Table mapping entry container and entry keys (suggestions and trigger) to loaded triggers. + *

+ * This is to ensure each trigger is loaded only once for command arguments composed of multiple + * command nodes. + * + * @see CommandArgument + */ + private static final ThreadLocal> LOADED_TRIGGERS + = ThreadLocal.withInitial(HashBasedTable::create); + + private CommandUtils() { + throw new UnsupportedOperationException(); + } + + /** + * Clears all loaded triggers. + *

+ * This should be called after command nodes are created to + * prevent storing further unused triggers. + */ + static void clearLoadedTriggers() { + LOADED_TRIGGERS.get().clear(); + } + + /** + * Checks whether the entry container can represent a command entry. + * + * @param entryContainer entry container + * @return whether the given container can represent a command entry + */ + public static boolean isValidCommandNode(EntryContainer entryContainer) { + if (!entryContainer.hasEntry(SubCommandEntryData.SUBCOMMAND_KEY) + && !entryContainer.hasEntry(SubCommandEntryData.TRIGGER_KEY)) { + Skript.error("Command node needs to have either a trigger or another sub-command entry."); + return false; + } + return true; + } + + /** + * Creates a command nodes from a sub command entry data. + *

+ * If parent is provided, the created nodes are added as its children. + * Parent properties are also copied to the created nodes. + * + * @param handler command handler + * @param subCommandData sub command entry data + * @param parent parent command node (inherits command properties), this is always present for + * sub command entries (either another sub command entry or the root command) + * @return created skript command nodes or empty list if the creation failed + */ + public static List> createCommandNode( + CommandHandler handler, SubCommandEntryData.Parsed subCommandData, + SkriptCommandNode.Builder parent) { + Preconditions.checkNotNull(parent, "Sub-command entry data always have a parent command node"); + if (subCommandData.entryContainer() == null || subCommandData.arguments() == null) + return Collections.emptyList(); + return createCommandNode(handler, subCommandData.entryContainer(), parent, subCommandData.arguments()); + } + + /** + * Creates a command nodes from an entry container and parsed command arguments. + *

+ * If parent is provided, the created nodes are added as its children. + * Parent properties are also copied to the created nodes. + * + * @param handler command handler + * @param entryContainer entry container + * @param parent parent command node (inherits command properties) + * @param arguments arguments + * @return created skript command nodes or empty list if the creation failed + */ + public static List> createCommandNode( + CommandHandler handler, EntryContainer entryContainer, + @Nullable SkriptCommandNode.Builder parent, List arguments) { + if (arguments.isEmpty()) { + Skript.error("Command nodes can not be empty; arguments are missing"); + return Collections.emptyList(); + } + + // list of builder groups (lists), each representing nodes of one parsed command argument + List>> builderGroups = new LinkedList<>(); + + for (int i = 0; i < arguments.size(); i++) { + var builders = arguments.get(i).emptyBuilders(); + for (SkriptCommandNode.Builder builder : builders) { + // first we copy parent properties + copyParentProperties(builder, parent); + // now we override them if they are present in the entry container for this node + if (!setCommandNodeProperties(handler, entryContainer, builder, i == arguments.size() - 1)) + return Collections.emptyList(); + } + builderGroups.add(builders); + } + + // now we prepared all builders, it is time to connect them together, from last to first one + for (int i = builderGroups.size() - 1; i > 0; i--) { + for (SkriptCommandNode.Builder previous : builderGroups.get(i - 1)) { + builderGroups.get(i).forEach(previous::then); + } + } + + var firstNodes = builderGroups.get(0).stream().map(SkriptCommandNode.Builder::build).toList(); + if (parent != null) + firstNodes.forEach(parent::then); + return firstNodes; + } + + private static void copyParentProperties(SkriptCommandNode.Builder builder, + @Nullable SkriptCommandNode.Builder parent) { + if (parent == null) return; + builder.permission(parent.getPermission()); + builder.permissionMessage(parent.getPermissionMessage()); + builder.possibleSources(parent.getPossibleSources()); + builder.cooldown(parent.getCooldown()); + } + + /** + * Updates the command node builder with command properties of given entry container. + * + * @param handler command handler + * @param entryContainer entry container + * @param builder builder + * @param last whether the node is at the end of the command argument + * definition, e.g. for this command '/foo bar world', 'world' is the last node + * @return false if the provided data are not compatible with given handler, else true + */ + public static boolean setCommandNodeProperties(CommandHandler handler, + EntryContainer entryContainer, SkriptCommandNode.Builder builder, + boolean last) { + + // root command properties + if (builder instanceof RootSkriptCommandNode.Builder rootBuilder) { + if (entryContainer.hasEntry(StructGeneralCommand.DESCRIPTION_KEY)) { + rootBuilder.description(entryContainer.get(StructGeneralCommand.DESCRIPTION_KEY, String.class, + false)); + } + if (entryContainer.hasEntry(StructGeneralCommand.USAGE_KEY)) { + VariableString usage = entryContainer.getOptional(StructGeneralCommand.USAGE_KEY, VariableString.class, + false); + // brigadier commands get too complex to create meaningful usage, default to '/literal' + rootBuilder.usage(new CommandUsage(usage, "/" + rootBuilder.getLiteral())); + } + if (entryContainer.hasEntry(StructGeneralCommand.ALIASES_KEY)) { + //noinspection unchecked + rootBuilder.aliases(entryContainer.get(StructGeneralCommand.ALIASES_KEY, List.class, + true)); + } + } + + if (entryContainer.hasEntry(SubCommandEntryData.PERMISSION_KEY)) { + builder.permission(entryContainer.getOptional(SubCommandEntryData.PERMISSION_KEY, + String.class, false)); + } + if (entryContainer.hasEntry(SubCommandEntryData.PERMISSION_MSG_KEY)) { + builder.permissionMessage(entryContainer.getOptional(SubCommandEntryData.PERMISSION_MSG_KEY, + VariableString.class, false)); + } + + if (entryContainer.hasEntry(SubCommandEntryData.EXECUTABLE_KEY)) { + List possibleSources = new LinkedList<>(); + for (Object source : entryContainer.get(SubCommandEntryData.EXECUTABLE_KEY, List.class, true)) { + var found = handler.supportedTypes().stream() + .filter(type -> type.names().contains(source.toString())) + .findFirst(); + if (found.isPresent()) { + possibleSources.add(found.get()); + continue; + } + Skript.error("Invalid command source type: " + source); + return false; + } + builder.possibleSources(possibleSources); + } + + // TODO cooldown + + // suggestions, trigger and children are only considered for the last node + if (!last) + return true; + + if (entryContainer.hasEntry(SubCommandEntryData.SUGGESTIONS_KEY)) { + if (!(builder instanceof ArgumentSkriptCommandNode.Builder)) { + Skript.error("Custom suggestions can only be provided for argument nodes"); + return false; + } + SectionNode suggestionsNode = entryContainer.get(SubCommandEntryData.SUGGESTIONS_KEY, + SectionNode.class, false); + + ReturnHandler suggestionsReturnHandler = new ReturnHandler<>() { + @Override + public void returnValues(Event event, Expression value) { + if (!(event instanceof BrigadierSuggestionsEvent bse)) return; + bse.setSuggestions(value.getAll(event)); + } + + @Override + public boolean isSingleReturnValue() { + return false; + } + + @Override + public @NotNull Class returnValueType() { + return String.class; + } + }; + + Trigger trigger = LOADED_TRIGGERS.get().row(entryContainer).computeIfAbsent( + SubCommandEntryData.SUGGESTIONS_KEY, key -> { + ParserInstance parser = ParserInstance.get(); + ParserInstance.Backup parserBackup = parser.backup(); + parser.reset(); + parser.setCurrentEvent("command suggestions", BrigadierSuggestionsEvent.class); + Trigger loaded = suggestionsReturnHandler.loadReturnableTrigger(suggestionsNode, + "command suggestions", new SimpleEvent()); + loaded.setLineNumber(suggestionsNode.getLine()); + parser.restoreBackup(parserBackup); + return loaded; + }); + + SkriptSuggestionProvider suggestionProvider = ctx -> { + BrigadierSuggestionsEvent event = new BrigadierSuggestionsEvent(ctx); + runTriggerOnMainThread(trigger, event); + // the chat message parsing can be done async + return CompletableFuture.supplyAsync(() -> Arrays.stream(event.getSuggestions()) + .flatMap(s -> ChatMessages.parse(s).stream()) + .toList()); + }; + + //noinspection unchecked + ((ArgumentSkriptCommandNode.Builder) builder).suggests(suggestionProvider); + } + + if (entryContainer.hasEntry(SubCommandEntryData.TRIGGER_KEY)) { + Trigger trigger = LOADED_TRIGGERS.get().row(entryContainer).computeIfAbsent( + SubCommandEntryData.TRIGGER_KEY, key -> { + SectionNode triggerNode = entryContainer.get(SubCommandEntryData.TRIGGER_KEY, SectionNode.class, + false); + ParserInstance parser = ParserInstance.get(); + ParserInstance.Backup parserBackup = parser.backup(); + parser.reset(); + parser.setCurrentEvent("command trigger", BrigadierCommandEvent.class); + Trigger loaded = new Trigger(ParserInstance.get().getCurrentScript(), + "command trigger", new SimpleEvent(), ScriptLoader.loadItems(triggerNode)); + loaded.setLineNumber(triggerNode.getLine()); + parser.restoreBackup(parserBackup); + return loaded; + }); + builder.executes(ctx -> runTriggerOnMainThread(trigger, new BrigadierCommandEvent(ctx)) ? 1 : 0); + } + + if (entryContainer.hasEntry(SubCommandEntryData.SUBCOMMAND_KEY)) { + List subCommands = entryContainer.getAll(SubCommandEntryData.SUBCOMMAND_KEY, + SubCommandEntryData.Parsed.class, false); + for (SubCommandEntryData.Parsed subCommand : subCommands) { + if (createCommandNode(handler, subCommand, builder).isEmpty()) + return false; + } + } + + return true; + } + + private static boolean runTriggerOnMainThread(Trigger trigger, Event event) { + if (Bukkit.isPrimaryThread()) { + return trigger.execute(event); + } else { + Executor executor = Bukkit.getScheduler().getMainThreadExecutor(Skript.getInstance()); + try { + return CompletableFuture.supplyAsync(() -> trigger.execute(event), executor).get(); + } catch (Exception exception) { + return false; + } + } + } + +} diff --git a/src/main/java/org/skriptlang/skript/lang/command/SkriptCommandSender.java b/src/main/java/org/skriptlang/skript/lang/command/SkriptCommandSender.java new file mode 100644 index 00000000000..fc206d7d657 --- /dev/null +++ b/src/main/java/org/skriptlang/skript/lang/command/SkriptCommandSender.java @@ -0,0 +1,41 @@ +package org.skriptlang.skript.lang.command; + +import ch.njol.skript.util.chat.MessageComponent; +import org.jetbrains.annotations.Nullable; + +import java.util.List; +import java.util.UUID; + +/** + * Executor of a Skript command. + */ +public interface SkriptCommandSender { + + /** + * Sends message to the command sender. + * + * @param message message to send + */ + void sendMessage(String message); + + /** + * Sends message components to the command sender. + * + * @param components components to send + */ + void sendMessage(List components); + + /** + * @return the unique ID of the sender, if one exists. + */ + @Nullable UUID getUniqueID(); + + /** + * Checks whether this sender has the given permission. + * + * @param permission the permission to check + * @return whether the sender has the given permission + */ + boolean hasPermission(String permission); + +} diff --git a/src/main/java/org/skriptlang/skript/lang/command/StructGeneralCommand.java b/src/main/java/org/skriptlang/skript/lang/command/StructGeneralCommand.java new file mode 100644 index 00000000000..ad8a2975a61 --- /dev/null +++ b/src/main/java/org/skriptlang/skript/lang/command/StructGeneralCommand.java @@ -0,0 +1,177 @@ +package org.skriptlang.skript.lang.command; + +import ch.njol.skript.ScriptLoader; +import ch.njol.skript.lang.Literal; +import ch.njol.skript.lang.SkriptParser; +import com.google.common.base.Preconditions; +import org.jetbrains.annotations.Nullable; +import org.skriptlang.skript.addon.SkriptAddon; +import org.skriptlang.skript.brigadier.RootSkriptCommandNode; +import org.skriptlang.skript.brigadier.SkriptCommandNode; +import org.skriptlang.skript.lang.entry.EntryContainer; +import org.skriptlang.skript.lang.entry.EntryValidator; +import org.skriptlang.skript.lang.entry.KeyValueEntryData; +import org.skriptlang.skript.lang.entry.util.VariableStringEntryData; +import org.skriptlang.skript.lang.structure.Structure; +import org.skriptlang.skript.registration.SyntaxInfo; +import org.skriptlang.skript.registration.SyntaxRegistry; + +import java.util.*; +import java.util.function.Consumer; +import java.util.regex.MatchResult; +import java.util.regex.Pattern; + +/** + * Represents an abstract base class for command structures within Skript. + */ +public abstract class StructGeneralCommand extends Structure { + + // syntax pattern for the command structure + private static final String COMMAND_PATTERN = "command [/]<^(\\S+)\\s*(.+)?>"; + + public static final String DEFAULT_NAMESPACE = "skript"; + + // additional command entry data keys + public static final String NAMESPACE_KEY = "namespace"; + public static final String DESCRIPTION_KEY = "description"; + public static final String USAGE_KEY = "usage"; + public static final String ALIASES_KEY = "aliases"; + + /** + * Registers a command structure. + *

+ * This method provides easy way to register the command structure with already predefined + * syntax and entry validator that can be further modified with provided consumer. + * + * @param addon addon to register the command structure for + * @param structureClass command structure to be registered + * @param builder consumer for further modification of the syntax info + * @param validatorBuilder consumer for further modification of the entry validator + * @param prefixPatterns string prefixes that define the start of the command structure's syntax. + * Those can not contain expressions or regex. + * @param command structure type + */ + protected static void registerCommandStructure(SkriptAddon addon, + Class structureClass, + @Nullable Consumer, E>> builder, + @Nullable Consumer validatorBuilder, + String... prefixPatterns) { + // patterns + List patterns = Arrays.stream(prefixPatterns) + .peek(prefix -> Preconditions.checkArgument(!prefix.contains("%"), + "Command prefix can not contain expressions")) + .peek(prefix -> Preconditions.checkArgument(!prefix.contains("<") || prefix.contains(">"), + "Command prefix can not contain regex")) + .map(p -> p.trim().concat(" " + COMMAND_PATTERN)) + .distinct().toList(); + // validator + EntryValidator validator = SubCommandEntryData.validator(vb -> { + vb + .addEntry(NAMESPACE_KEY, DEFAULT_NAMESPACE, true) + .addEntry(DESCRIPTION_KEY, null, true) + .addEntryData(new VariableStringEntryData(USAGE_KEY, null, true)) + .addEntryData(new KeyValueEntryData>(ALIASES_KEY, new ArrayList<>(), true) { + private final Pattern pattern = Pattern.compile("\\s*,\\s*/?"); + @Override + protected List getValue(String value) { + List aliases = new ArrayList<>(Arrays.asList(pattern.split(value))); + if (aliases.get(0).startsWith("/")) { + aliases.set(0, aliases.get(0).substring(1)); + } else if (aliases.get(0).isEmpty()) { + aliases = new ArrayList<>(0); + } + return aliases; + } + }); + // if provided, accept modifications + if (validatorBuilder != null) + validatorBuilder.accept(vb); + }); + // syntax info + var building = SyntaxInfo.Structure.builder(structureClass) + .addPatterns(patterns) + .entryValidator(validator); + // if provided, accept modifications + if (builder != null) + builder.accept(building); + // register + addon.syntaxRegistry().register(SyntaxRegistry.STRUCTURE, building.build()); + } + + /** + * Registers a command structure. + *

+ * This method provides easy way to register the command structure with already predefined syntax. + * + * @param addon addon to register the structure for + * @param structureClass command structure to be registered + * @param prefixPatterns string prefixes that define the start of the command structure's syntax. + * Those can not contain expressions or regex. + * @param command structure type + */ + protected static void registerCommandStructure(SkriptAddon addon, + Class structureClass, String... prefixPatterns) { + registerCommandStructure(addon, structureClass, null, null, prefixPatterns); + } + + protected EntryContainer entryContainer; + protected RootSkriptCommandNode commandNode; + + /** + * Command handler used for the registration and creation of the commands. + * + * @return command handler + */ + public abstract CommandHandler getHandler(); + + @Override + public boolean init(Literal[] args, int matchedPattern, SkriptParser.ParseResult parseResult, + @Nullable EntryContainer entryContainer) { + assert entryContainer != null; // cannot be null for non-simple structures + this.entryContainer = entryContainer; + MatchResult matchResult = parseResult.regexes.get(0); + + String label = ScriptLoader.replaceOptions(matchResult.group(1)); + String namespace = entryContainer.get(NAMESPACE_KEY, String.class, true); + List arguments = Collections.emptyList(); + String rawArguments = matchResult.group(2); + + if (rawArguments != null) { + arguments = CommandArgumentParser.parse(ScriptLoader.replaceOptions(rawArguments)); + if (arguments.isEmpty() && !rawArguments.isBlank()) { + return false; // parsing failed + } + } + + if (!CommandUtils.isValidCommandNode(entryContainer)) + return false; + + List rootArguments = new ArrayList<>(); + rootArguments.add(new CommandArgument.Root(namespace, label)); + rootArguments.addAll(arguments); + + CommandArgumentParser.resetArgumentCounter(); + // the created nodes should be a list of length 1 (the root skript command node) or empty + // if the creation failed + List> createdNodes = CommandUtils.createCommandNode(getHandler(), + entryContainer, null, rootArguments); + CommandUtils.clearLoadedTriggers(); + if (createdNodes.isEmpty()) + return false; + commandNode = (RootSkriptCommandNode) createdNodes.get(0); + return true; + } + + @Override + public boolean load() { + if (commandNode == null) return false; + return getHandler().registerCommand(commandNode); + } + + @Override + public void unload() { + if (commandNode == null) return; + getHandler().unregisterCommand(commandNode); + } + +} diff --git a/src/main/java/org/skriptlang/skript/lang/command/SubCommandEntryData.java b/src/main/java/org/skriptlang/skript/lang/command/SubCommandEntryData.java new file mode 100644 index 00000000000..43de79e243b --- /dev/null +++ b/src/main/java/org/skriptlang/skript/lang/command/SubCommandEntryData.java @@ -0,0 +1,167 @@ +package org.skriptlang.skript.lang.command; + +import ch.njol.skript.ScriptLoader; +import ch.njol.skript.Skript; +import ch.njol.skript.config.Node; +import ch.njol.skript.config.SectionNode; +import ch.njol.skript.util.StringMode; +import ch.njol.skript.util.Timespan; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.Unmodifiable; +import org.skriptlang.skript.lang.entry.EntryContainer; +import org.skriptlang.skript.lang.entry.EntryData; +import org.skriptlang.skript.lang.entry.EntryValidator; +import org.skriptlang.skript.lang.entry.KeyValueEntryData; +import org.skriptlang.skript.lang.entry.util.LiteralEntryData; +import org.skriptlang.skript.lang.entry.util.VariableStringEntryData; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Entry data used for sub-commands. + *

+ * This entry data can be repeatedly used within general command structure and + * also repeatedly within itself. + *

+ * For the sub-command entry to be considered valid, it needs to either + * have a trigger section, + * or have other sub command entry. + */ +public class SubCommandEntryData extends EntryData { + + // pattern used to match the subcommand node + private static final Pattern SUBCOMMAND_PATTERN = Pattern.compile("^sub-?command\\s+(.+)$"); + + // sub-command entry data keys + public static final String PERMISSION_KEY = "permission"; + // TODO permissions messages should be removed completely, + // instead of a message the command is not sent to client + // and dispatcher ignores it + public static final String PERMISSION_MSG_KEY = "permission message"; + public static final String EXECUTABLE_KEY = "executable by"; + public static final String COOLDOWN_KEY = "cooldown"; + public static final String COOLDOWN_MSG_KEY = "cooldown message"; + public static final String COOLDOWN_BYPASS_KEY = "cooldown bypass"; + public static final String COOLDOWN_STORAGE_KEY = "cooldown storage"; + public static final String SUGGESTIONS_KEY = "suggestions"; + public static final String TRIGGER_KEY = "trigger"; + public static final String SUBCOMMAND_KEY = "subcommand"; + + private static final EntryValidator VALIDATOR = validator(); + + /** + * Creates entry validator for a sub command entry. + * + * @return entry validator for sub command entry + */ + public static EntryValidator validator() { + return validator(builder -> {}); + } + + /** + * Creates entry validator for sub command entry which is possible to + * further modify with given consumer. + * + * @param builderConsumer consumer used to further modify the entry validator + * @return entry validator for sub command entry + */ + public static EntryValidator validator(Consumer builderConsumer) { + EntryValidator.EntryValidatorBuilder builder = EntryValidator.builder(); + builder + // permissions + .addEntry(PERMISSION_KEY, null, true) + .addEntryData(new VariableStringEntryData(PERMISSION_MSG_KEY, null, true)) + // executable by + .addEntryData(new KeyValueEntryData>(EXECUTABLE_KEY, new ArrayList<>(), true) { + private final Pattern pattern = Pattern.compile("\\s*,\\s*|\\s+(and|or)\\s+"); + @Override + protected @NotNull List getValue(String value) { + return List.of(pattern.split(value)); + } + }) + // cooldown + .addEntryData(new LiteralEntryData<>(COOLDOWN_KEY, null, true, Timespan.class)) + .addEntryData(new VariableStringEntryData(COOLDOWN_MSG_KEY, null, true)) + .addEntry(COOLDOWN_BYPASS_KEY, null, true) + .addEntryData(new VariableStringEntryData(COOLDOWN_STORAGE_KEY, null, + true, StringMode.VARIABLE_NAME)) + // suggestions (tab completions) + .addSection(SUGGESTIONS_KEY, true) + // trigger + .addSection(TRIGGER_KEY, true) + // subcommands + .addEntryData(new SubCommandEntryData()) + .unexpectedEntryMessage(key -> + "Unexpected entry '" + key + "'. Check that it's spelled correctly, and ensure that you have " + + "put all code into a trigger." + ); + builderConsumer.accept(builder); + return builder.build(); + } + + protected @Nullable SectionNode node; + protected @Nullable Parsed parsed; + + protected SubCommandEntryData() { + super(SUBCOMMAND_KEY, null, true, true); + } + + @Override + public @Nullable Parsed getValue(Node node) { + if (this.node != null && this.node == node) return parsed; + if (!canCreateWith(node)) return null; + return parsed; + } + + @Override + public boolean canCreateWith(Node node) { + if (!(node instanceof SectionNode sectionNode)) + return false; + String key = node.getKey(); + if (key == null) + return false; + key = ScriptLoader.replaceOptions(key); + Matcher subCommandMatcher = SUBCOMMAND_PATTERN.matcher(key); + if (!subCommandMatcher.matches()) + return false; + EntryContainer entryContainer = VALIDATOR.validate(sectionNode); + if (entryContainer == null) + return false; + if (!CommandUtils.isValidCommandNode(entryContainer)) + return false; + this.node = sectionNode; + + String rawArguments = subCommandMatcher.group(1).trim(); + if (rawArguments.isBlank()) { + Skript.error("Sub-commands must have arguments"); + return false; + } + + List arguments = CommandArgumentParser.parse(subCommandMatcher.group(1)); + if (arguments.isEmpty()) + return false; + + parsed = new Parsed(entryContainer, arguments); + return true; + } + + /** + * Represents a parsed sub command entry. + * + * @param entryContainer entry container of the sub command entry + * @param arguments arguments of the sub command + */ + public record Parsed(EntryContainer entryContainer, @Unmodifiable List arguments) { + + public Parsed { + arguments = List.copyOf(arguments); + } + + } + +} diff --git a/src/main/java/org/skriptlang/skript/lang/entry/ContainerEntryData.java b/src/main/java/org/skriptlang/skript/lang/entry/ContainerEntryData.java index 342be3bd9fe..059caf259d5 100644 --- a/src/main/java/org/skriptlang/skript/lang/entry/ContainerEntryData.java +++ b/src/main/java/org/skriptlang/skript/lang/entry/ContainerEntryData.java @@ -25,6 +25,18 @@ public ContainerEntryData(String key, boolean optional, EntryValidatorBuilder va this.entryValidator = validatorBuilder.build(); } + public ContainerEntryData(String key, boolean optional, boolean multiple, EntryValidator entryValidator) { + super(key, null, optional, multiple); + this.entryValidator = entryValidator; + } + + public ContainerEntryData( + String key, boolean optional, boolean multiple, EntryValidatorBuilder validatorBuilder + ) { + super(key, null, optional, multiple); + this.entryValidator = validatorBuilder.build(); + } + public EntryValidator getEntryValidator() { return entryValidator; } @@ -44,8 +56,7 @@ public boolean canCreateWith(Node node) { key = ScriptLoader.replaceOptions(key); if (!getKey().equalsIgnoreCase(key)) return false; - EntryContainer container = entryValidator.validate(sectionNode); - entryContainer = container; + entryContainer = entryValidator.validate(sectionNode); return true; } diff --git a/src/main/java/org/skriptlang/skript/lang/entry/EntryContainer.java b/src/main/java/org/skriptlang/skript/lang/entry/EntryContainer.java index 6c2dfb59d2d..1f1740b7d58 100644 --- a/src/main/java/org/skriptlang/skript/lang/entry/EntryContainer.java +++ b/src/main/java/org/skriptlang/skript/lang/entry/EntryContainer.java @@ -5,10 +5,9 @@ import ch.njol.skript.lang.parser.ParserInstance; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.Unmodifiable; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; +import java.util.*; /** * An EntryContainer is a data container for obtaining the values of the entries of a {@link SectionNode}. @@ -16,14 +15,13 @@ public class EntryContainer { private final SectionNode source; - @Nullable - private final EntryValidator entryValidator; - @Nullable - private final Map handledNodes; + private final @Nullable EntryValidator entryValidator; + private final @Nullable Map> handledNodes; private final List unhandledNodes; EntryContainer( - SectionNode source, @Nullable EntryValidator entryValidator, @Nullable Map handledNodes, List unhandledNodes + SectionNode source, @Nullable EntryValidator entryValidator, + @Nullable Map> handledNodes, List unhandledNodes ) { this.source = source; this.entryValidator = entryValidator; @@ -60,11 +58,72 @@ public List getUnhandledNodes() { return unhandledNodes; } + /** + * A method for obtaining a typed entry values. + * @param key The key associated with the entry. + * @param expectedType The class representing the expected type of the entry's values. + * @param useDefaultValue Whether the default value should be used if parsing failed. + * @return The entry's values. May be empty list if the entry is missing or a parsing error occurred. + * @throws RuntimeException If the entry's value is not of the expected type. + */ + @SuppressWarnings("unchecked") + public @Unmodifiable List getAll(String key, Class expectedType, boolean useDefaultValue) { + List parsed = getAll(key, useDefaultValue); + for (Object object : parsed) { + if (!expectedType.isInstance(object)) + throw new RuntimeException("Expected entry with key '" + key + "' to be '" + + expectedType + "', but got '" + object.getClass() + "'"); + } + return (List) parsed; + } + + /** + * A method for obtaining an entry values with an unknown type. + * @param key The key associated with the entry. + * @param useDefaultValue Whether the default value should be used if parsing failed. + * @return The entry's values. May be empty list if the entry is missing or a parsing error occurred. + */ + public @Unmodifiable List getAll(String key, boolean useDefaultValue) { + if (entryValidator == null || handledNodes == null) + return Collections.emptyList(); + + EntryData entryData = entryValidator.getEntryData().stream() + .filter(data -> data.getKey().equals(key)) + .findFirst() + .orElse(null); + if (entryData == null) + return Collections.emptyList(); + + Collection nodes = handledNodes.get(key); + if (nodes == null || nodes.isEmpty()) { + Object defaultValue = entryData.getDefaultValue(); + return defaultValue != null + ? Collections.singletonList(defaultValue) + : Collections.emptyList(); + } + + List values = new LinkedList<>(); + ParserInstance parser = ParserInstance.get(); + Node oldNode = parser.getNode(); + for (Node node : nodes) { + parser.setNode(node); + Object value = entryData.getValue(node); + if (value == null && useDefaultValue) + value = entryData.getDefaultValue(); + if (value != null) + values.add(value); + } + parser.setNode(oldNode); + + return Collections.unmodifiableList(values); + } + /** * A method for obtaining a non-null, typed entry value. * This method should ONLY be called if there is no way the entry could return null. - * In general, this means that the entry has a default value (and 'useDefaultValue' is true). This is because even - * though an entry may be required, parsing errors may occur that mean no value can be returned. + * In general, this means that the entry has a default value (and 'useDefaultValue' is true). + * This is because even though an entry may be required, parsing errors may occur that + * mean no value can be returned. * It can also mean that the entry data is simple enough such that it will never return a null value. * @param key The key associated with the entry. * @param expectedType The class representing the expected type of the entry's value. @@ -73,17 +132,18 @@ public List getUnhandledNodes() { * @throws RuntimeException If the entry's value is null, or if it is not of the expected type. */ public R get(String key, Class expectedType, boolean useDefaultValue) { - R value = getOptional(key, expectedType, useDefaultValue); - if (value == null) + List all = getAll(key, expectedType, useDefaultValue); + if (all.isEmpty()) throw new RuntimeException("Null value for asserted non-null value"); - return value; + return all.get(0); // always present } /** * A method for obtaining a non-null entry value with an unknown type. * This method should ONLY be called if there is no way the entry could return null. - * In general, this means that the entry has a default value (and 'useDefaultValue' is true). This is because even - * though an entry may be required, parsing errors may occur that mean no value can be returned. + * In general, this means that the entry has a default value (and 'useDefaultValue' is true). + * This is because even though an entry may be required, parsing errors may occur that + * mean no value can be returned. * It can also mean that the entry data is simple enough such that it will never return a null value. * @param key The key associated with the entry. * @param useDefaultValue Whether the default value should be used if parsing failed. @@ -91,10 +151,10 @@ public R get(String key, Class expectedType, boolean useDefa * @throws RuntimeException If the entry's value is null. */ public Object get(String key, boolean useDefaultValue) { - Object parsed = getOptional(key, useDefaultValue); - if (parsed == null) + List all = getAll(key, useDefaultValue); + if (all.isEmpty()) throw new RuntimeException("Null value for asserted non-null value"); - return parsed; + return all.get(0); // always present } /** @@ -105,15 +165,9 @@ public Object get(String key, boolean useDefaultValue) { * @return The entry's value. May be null if the entry is missing or a parsing error occurred. * @throws RuntimeException If the entry's value is not of the expected type. */ - @Nullable - @SuppressWarnings("unchecked") - public R getOptional(String key, Class expectedType, boolean useDefaultValue) { - Object parsed = getOptional(key, useDefaultValue); - if (parsed == null) - return null; - if (!expectedType.isInstance(parsed)) - throw new RuntimeException("Expected entry with key '" + key + "' to be '" + expectedType + "', but got '" + parsed.getClass() + "'"); - return (R) parsed; + public @Nullable R getOptional(String key, Class expectedType, boolean useDefaultValue) { + List all = getAll(key, expectedType, useDefaultValue); + return all.isEmpty() ? null : all.get(0); } /** @@ -122,35 +176,9 @@ public R getOptional(String key, Class expectedType, boolean * @param useDefaultValue Whether the default value should be used if parsing failed. * @return The entry's value. May be null if the entry is missing or a parsing error occurred. */ - @Nullable - public Object getOptional(String key, boolean useDefaultValue) { - if (entryValidator == null || handledNodes == null) - return null; - - EntryData entryData = null; - for (EntryData data : entryValidator.getEntryData()) { - if (data.getKey().equals(key)) { - entryData = data; - break; - } - } - if (entryData == null) - return null; - - Node node = handledNodes.get(key); - if (node == null) - return entryData.getDefaultValue(); - - // Update ParserInstance node for parsing - ParserInstance parser = ParserInstance.get(); - Node oldNode = parser.getNode(); - parser.setNode(node); - Object value = entryData.getValue(node); - if (value == null && useDefaultValue) - value = entryData.getDefaultValue(); - parser.setNode(oldNode); - - return value; + public @Nullable Object getOptional(String key, boolean useDefaultValue) { + List all = getAll(key, useDefaultValue); + return all.isEmpty() ? null : all.get(0); } /** @@ -159,7 +187,7 @@ public Object getOptional(String key, boolean useDefaultValue) { * @return true if an entry data with the matching key was used. */ public boolean hasEntry(@NotNull String key) { - return handledNodes.containsKey(key); + return handledNodes != null && handledNodes.containsKey(key); } } diff --git a/src/main/java/org/skriptlang/skript/lang/entry/EntryData.java b/src/main/java/org/skriptlang/skript/lang/entry/EntryData.java index 2be30f7e5cb..4a0ed9a5232 100644 --- a/src/main/java/org/skriptlang/skript/lang/entry/EntryData.java +++ b/src/main/java/org/skriptlang/skript/lang/entry/EntryData.java @@ -30,11 +30,17 @@ public abstract class EntryData { private final String key; private final @Nullable T defaultValue; private final boolean optional; + private final boolean multiple; public EntryData(String key, @Nullable T defaultValue, boolean optional) { + this(key, defaultValue, optional, false); + } + + public EntryData(String key, @Nullable T defaultValue, boolean optional, boolean multiple) { this.key = key; this.defaultValue = defaultValue; this.optional = optional; + this.multiple = multiple; } /** @@ -59,6 +65,13 @@ public boolean isOptional() { return optional; } + /** + * @return Whether this entry data can be included repeatedly within a {@link SectionNode}. + */ + public boolean supportsMultiple() { + return multiple; + } + /** * Obtains a value from the provided node using the methods of this entry data. * @param node The node to obtain a value from. diff --git a/src/main/java/org/skriptlang/skript/lang/entry/EntryValidator.java b/src/main/java/org/skriptlang/skript/lang/entry/EntryValidator.java index e3fc1054153..85da3f2ea21 100644 --- a/src/main/java/org/skriptlang/skript/lang/entry/EntryValidator.java +++ b/src/main/java/org/skriptlang/skript/lang/entry/EntryValidator.java @@ -6,12 +6,7 @@ import ch.njol.skript.config.SectionNode; import org.jetbrains.annotations.Nullable; -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashMap; -import java.util.Iterator; -import java.util.List; -import java.util.Map; +import java.util.*; import java.util.function.Function; import java.util.function.Predicate; @@ -50,10 +45,12 @@ private EntryValidator( ) { this.entryData = entryData; this.unexpectedNodeTester = unexpectedNodeTester; - this.unexpectedEntryMessage = - unexpectedEntryMessage != null ? unexpectedEntryMessage : DEFAULT_UNEXPECTED_ENTRY_MESSAGE; - this.missingRequiredEntryMessage = - missingRequiredEntryMessage != null ? missingRequiredEntryMessage : DEFAULT_MISSING_REQUIRED_ENTRY_MESSAGE; + this.unexpectedEntryMessage = unexpectedEntryMessage != null + ? unexpectedEntryMessage + : DEFAULT_UNEXPECTED_ENTRY_MESSAGE; + this.missingRequiredEntryMessage = missingRequiredEntryMessage != null + ? missingRequiredEntryMessage + : DEFAULT_MISSING_REQUIRED_ENTRY_MESSAGE; } /** @@ -66,13 +63,15 @@ public List> getEntryData() { /** * Validates a node using this entry validator. * @param sectionNode The node to validate. - * @return A pair containing a map of handled nodes and a list of unhandled nodes (if this validator permits unhandled nodes) - * The returned map uses the matched entry data's key as a key and uses a pair containing the entry data and matching node + * @return A pair containing a map of handled nodes and a list of unhandled nodes + * (if this validator permits unhandled nodes) + * The returned map uses the matched entry data's key as a key and + * uses a pair containing the entry data and matching node * Will return null if the provided node couldn't be validated. */ public @Nullable EntryContainer validate(SectionNode sectionNode) { List> entries = new ArrayList<>(entryData); - Map handledNodes = new HashMap<>(); + Map> handledNodes = new HashMap<>(); List unhandledNodes = new ArrayList<>(); boolean ok = true; @@ -85,8 +84,13 @@ public List> getEntryData() { while (iterator.hasNext()) { EntryData data = iterator.next(); if (data.canCreateWith(node)) { // Determine if it's a match - handledNodes.put(data.getKey(), node); // This is a known node, mark it as such - iterator.remove(); + Collection nodes = handledNodes.computeIfAbsent( + data.getKey(), k -> new LinkedList<>() + ); + nodes.add(node); + // we do not expect this entry data anymore + if (!data.supportsMultiple()) + iterator.remove(); continue nodeLoop; } } @@ -102,6 +106,11 @@ public List> getEntryData() { // Now we're going to check for missing entries that are *required* for (EntryData entryData : entries) { + // entries that can be included multiple times are not removed, + // so we skip them if they occurred at least once + if (entryData.supportsMultiple() && handledNodes.containsKey(entryData.getKey())) { + continue; + } if (!entryData.isOptional()) { Skript.error(missingRequiredEntryMessage.apply(entryData.getKey())); ok = false; @@ -115,7 +124,8 @@ public List> getEntryData() { } /** - * A utility builder for creating an entry validator that can be used to parse and validate a {@link SectionNode}. + * A utility builder for creating an entry validator that can be used to parse + * and validate a {@link SectionNode}. * @see EntryValidator#builder() */ public static class EntryValidatorBuilder { @@ -173,23 +183,40 @@ public EntryValidatorBuilder unexpectedEntryMessage(Function une * A function to be applied when a required Node is missing during validation. * A String representing the key of the missing entry goes in, * and an error message to output comes out. - * @param missingRequiredEntryMessage The function to use. + * @param message The function to use. * @return The builder instance. */ - public EntryValidatorBuilder missingRequiredEntryMessage(Function missingRequiredEntryMessage) { - this.missingRequiredEntryMessage = missingRequiredEntryMessage; + public EntryValidatorBuilder missingRequiredEntryMessage(Function message) { + this.missingRequiredEntryMessage = message; return this; } /** * Adds a new {@link KeyValueEntryData} to this validator that returns the raw, unhandled String value. * The added entry is optional and will use the provided default value as a backup. + * The entry data can be included only once within a single entry container. * @param key The key of the entry. * @param defaultValue The default value of this entry to use if the user does not include this entry. + * @param optional Whether the entry is optional * @return The builder instance. */ public EntryValidatorBuilder addEntry(String key, @Nullable String defaultValue, boolean optional) { - entryData.add(new KeyValueEntryData(key, defaultValue, optional) { + return addEntry(key, defaultValue, optional, false); + } + + /** + * Adds a new {@link KeyValueEntryData} to this validator that returns the raw, unhandled String value. + * The added entry is optional and will use the provided default value as a backup. + * @param key The key of the entry. + * @param defaultValue The default value of this entry to use if the user does not include this entry. + * @param optional Whether the entry is optional + * @param multiple Whether the entry can be included multiple times within a single section node + * @return The builder instance. + */ + public EntryValidatorBuilder addEntry( + String key, @Nullable String defaultValue, boolean optional, boolean multiple + ) { + entryData.add(new KeyValueEntryData<>(key, defaultValue, optional, multiple) { @Override protected String getValue(String value) { return value; @@ -205,18 +232,31 @@ public String getSeparator() { /** * Adds a new, potentially optional {@link SectionEntryData} to this validator. + * The entry data can be included only once within a single entry container. * @param key The key of the section entry. - * @param optional Whether this section entry should be optional. + * @param optional Whether the entry is optional * @return The builder instance. */ public EntryValidatorBuilder addSection(String key, boolean optional) { - entryData.add(new SectionEntryData(key, null, optional)); + return addSection(key, optional, false); + } + + /** + * Adds a new, potentially optional {@link SectionEntryData} to this validator. + * @param key The key of the section entry. + * @param optional Whether the entry is optional + * @param multiple Whether the entry can be included multiple times within a single section node + * @return The builder instance. + */ + public EntryValidatorBuilder addSection(String key, boolean optional, boolean multiple) { + entryData.add(new SectionEntryData(key, null, optional, multiple)); return this; } /** * A method to add custom {@link EntryData} to a validator. - * Custom entry data should be preferred when the default methods included in this builder are not expansive enough. + * Custom entry data should be preferred when the default methods included in + * this builder are not expansive enough. * Please note that for custom {@link KeyValueEntryData} implementations, the default entry separator * value of this builder will not be used. Instead, {@link #DEFAULT_ENTRY_SEPARATOR} will be used. * @param entryData The custom entry data to include in this validator. diff --git a/src/main/java/org/skriptlang/skript/lang/entry/KeyValueEntryData.java b/src/main/java/org/skriptlang/skript/lang/entry/KeyValueEntryData.java index 98fcf2863a2..2d56dcd5ba0 100644 --- a/src/main/java/org/skriptlang/skript/lang/entry/KeyValueEntryData.java +++ b/src/main/java/org/skriptlang/skript/lang/entry/KeyValueEntryData.java @@ -18,6 +18,10 @@ public KeyValueEntryData(String key, @Nullable T defaultValue, boolean optional) super(key, defaultValue, optional); } + public KeyValueEntryData(String key, @Nullable T defaultValue, boolean optional, boolean multiple) { + super(key, defaultValue, optional, multiple); + } + /** * Used to obtain and parse the value of a {@link SimpleNode}. This method accepts * any type of node, but assumes the input to be a {@link SimpleNode}. Before calling this method, @@ -31,7 +35,8 @@ public KeyValueEntryData(String key, @Nullable T defaultValue, boolean optional) String key = node.getKey(); if (key == null) throw new IllegalArgumentException("EntryData#getValue() called with invalid node."); - return getValue(ScriptLoader.replaceOptions(key).substring(getKey().length() + getSeparator().length())); + return getValue(ScriptLoader.replaceOptions(key) + .substring(getKey().length() + getSeparator().length())); } /** diff --git a/src/main/java/org/skriptlang/skript/lang/entry/SectionEntryData.java b/src/main/java/org/skriptlang/skript/lang/entry/SectionEntryData.java index 21fc561d250..1400c194f46 100644 --- a/src/main/java/org/skriptlang/skript/lang/entry/SectionEntryData.java +++ b/src/main/java/org/skriptlang/skript/lang/entry/SectionEntryData.java @@ -15,8 +15,13 @@ public SectionEntryData(String key, @Nullable SectionNode defaultValue, boolean super(key, defaultValue, optional); } + public SectionEntryData(String key, @Nullable SectionNode defaultValue, boolean optional, boolean multiple) { + super(key, defaultValue, optional, multiple); + } + /** - * Because this entry data is for {@link SectionNode}s, no specific handling needs to be done to obtain the "value". + * Because this entry data is for {@link SectionNode}s, + * no specific handling needs to be done to obtain the "value". * This method just asserts that the provided node is actually a {@link SectionNode}. * @param node A {@link SimpleNode} to obtain (and possibly convert) the value of. * @return The value obtained from the provided {@link SimpleNode}. diff --git a/src/main/java/org/skriptlang/skript/lang/entry/util/TriggerEntryData.java b/src/main/java/org/skriptlang/skript/lang/entry/util/TriggerEntryData.java index 7cc093e9473..09d23938587 100644 --- a/src/main/java/org/skriptlang/skript/lang/entry/util/TriggerEntryData.java +++ b/src/main/java/org/skriptlang/skript/lang/entry/util/TriggerEntryData.java @@ -8,8 +8,6 @@ import org.skriptlang.skript.lang.entry.EntryData; import org.skriptlang.skript.lang.entry.SectionEntryData; import ch.njol.skript.lang.util.SimpleEvent; -import ch.njol.util.Kleenean; -import org.bukkit.event.Event; import org.jetbrains.annotations.Nullable; /** @@ -23,6 +21,10 @@ public TriggerEntryData(String key, @Nullable Trigger defaultValue, boolean opti super(key, defaultValue, optional); } + public TriggerEntryData(String key, @Nullable Trigger defaultValue, boolean optional, boolean multiple) { + super(key, defaultValue, optional, multiple); + } + @Nullable @Override public Trigger getValue(Node node) { diff --git a/src/test/skript/junit/EntryContainerTest.sk b/src/test/skript/junit/EntryContainerTest.sk index 13376089928..d8d10e73178 100644 --- a/src/test/skript/junit/EntryContainerTest.sk +++ b/src/test/skript/junit/EntryContainerTest.sk @@ -1,9 +1,18 @@ options: - test: "org.skriptlang.skript.test.tests.syntaxes.events.EntryContainerTest" + test: "org.skriptlang.skript.test.tests.lang.EntryContainerTest" test "EntryContainerTest" when running JUnit: - ensure junit test {@test} completes "has entry" + set {_tests::1} to "has entry" + set {_tests::2} to "has multiple entry" + ensure junit test {@test} completes {_tests::*} test entry container: has entry: complete objective "has entry" for {@test} + has multiple entry: + set {entry-container-test::call-count} to 0 + add 1 to {entry-container-test::call-count} + has multiple entry: + add 1 to {entry-container-test::call-count} + if {entry-container-test::call-count} is 2: + complete objective "has multiple entry" for {@test}