diff --git a/paper-api/src/main/java/com/destroystokyo/paper/event/server/AsyncTabCompleteEvent.java b/paper-api/src/main/java/com/destroystokyo/paper/event/server/AsyncTabCompleteEvent.java new file mode 100644 index 0000000000..0482ecf5b8 --- /dev/null +++ b/paper-api/src/main/java/com/destroystokyo/paper/event/server/AsyncTabCompleteEvent.java @@ -0,0 +1,333 @@ +/* + * Copyright (c) 2017 Daniel Ennis (Aikar) MIT License + * + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software and associated documentation files (the + * "Software"), to deal in the Software without restriction, including + * without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to + * the following conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE + * LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package com.destroystokyo.paper.event.server; + +import com.google.common.base.Preconditions; +import io.papermc.paper.util.TransformingRandomAccessList; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.stream.Stream; +import net.kyori.adventure.text.Component; +import net.kyori.examination.Examinable; +import net.kyori.examination.ExaminableProperty; +import net.kyori.examination.string.StringExaminer; +import org.bukkit.Location; +import org.bukkit.command.Command; +import org.bukkit.command.CommandSender; +import org.bukkit.event.Cancellable; +import org.bukkit.event.Event; +import org.bukkit.event.HandlerList; +import org.jetbrains.annotations.ApiStatus; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +/** + * Allows plugins to compute tab completion results asynchronously. + *

+ * If this event provides completions, then the standard synchronous process + * will not be fired to populate the results. + * However, the synchronous TabCompleteEvent will fire with the Async results. + *

+ * Only 1 process will be allowed to provide completions, the Async Event, or the standard process. + */ +@NullMarked +public class AsyncTabCompleteEvent extends Event implements Cancellable { + + private static final HandlerList HANDLER_LIST = new HandlerList(); + + private final CommandSender sender; + private final String buffer; + private final boolean isCommand; + private final @Nullable Location location; + private final List completions = new ArrayList<>(); + private final List stringCompletions = new TransformingRandomAccessList<>( + this.completions, + Completion::suggestion, + Completion::completion + ); + private boolean handled; + private boolean cancelled; + + @ApiStatus.Internal + public AsyncTabCompleteEvent(final CommandSender sender, final String buffer, final boolean isCommand, final @Nullable Location loc) { + super(true); + this.sender = sender; + this.buffer = buffer; + this.isCommand = isCommand; + this.location = loc; + } + + @Deprecated + @ApiStatus.Internal + public AsyncTabCompleteEvent(final CommandSender sender, final List completions, final String buffer, final boolean isCommand, final @Nullable Location loc) { + super(true); + this.sender = sender; + this.completions.addAll(fromStrings(completions)); + this.buffer = buffer; + this.isCommand = isCommand; + this.location = loc; + } + + /** + * Get the sender completing this command. + * + * @return the {@link CommandSender} instance + */ + public CommandSender getSender() { + return this.sender; + } + + /** + * The list of completions which will be offered to the sender, in order. + * This list is mutable and reflects what will be offered. + *

+ * If this collection is not empty after the event is fired, then + * the standard process of calling {@link Command#tabComplete(CommandSender, String, String[])} + * or current player names will not be called. + * + * @return a list of offered completions + */ + public List getCompletions() { + return this.stringCompletions; + } + + /** + * Set the completions offered, overriding any already set. + * If this collection is not empty after the event is fired, then + * the standard process of calling {@link Command#tabComplete(CommandSender, String, String[])} + * or current player names will not be called. + *

+ * The passed collection will be cloned to a new {@code List}. You must call {{@link #getCompletions()}} to mutate from here + * + * @param completions the new completions + */ + public void setCompletions(final List completions) { + Preconditions.checkArgument(completions != null, "Completions list cannot be null"); + if (completions == this.stringCompletions) { + return; + } + this.completions.clear(); + this.completions.addAll(fromStrings(completions)); + } + + /** + * The list of {@link Completion completions} which will be offered to the sender, in order. + * This list is mutable and reflects what will be offered. + *

+ * If this collection is not empty after the event is fired, then + * the standard process of calling {@link Command#tabComplete(CommandSender, String, String[])} + * or current player names will not be called. + * + * @return a list of offered completions + */ + public List completions() { + return this.completions; + } + + /** + * Set the {@link Completion completions} offered, overriding any already set. + * If this collection is not empty after the event is fired, then + * the standard process of calling {@link Command#tabComplete(CommandSender, String, String[])} + * or current player names will not be called. + *

+ * The passed collection will be cloned to a new {@code List}. You must call {@link #completions()} to mutate from here + * + * @param newCompletions the new completions + */ + public void completions(final List newCompletions) { + Preconditions.checkArgument(newCompletions != null, "new completions cannot be null"); + this.completions.clear(); + this.completions.addAll(newCompletions); + } + + /** + * Return the entire buffer which formed the basis of this completion. + * + * @return command buffer, as entered + */ + public String getBuffer() { + return this.buffer; + } + + /** + * @return {@code true} if it is a command being tab completed, {@code false} if it is a chat message. + */ + public boolean isCommand() { + return this.isCommand; + } + + /** + * @return The position looked at by the sender, or {@code null} if none + */ + public @Nullable Location getLocation() { + return this.location != null ? this.location.clone() : null; + } + + /** + * If {@code true}, the standard process of calling {@link Command#tabComplete(CommandSender, String, String[])} + * or current player names will not be called. + * + * @return Is completions considered handled. Always {@code true} if completions is not empty. + */ + public boolean isHandled() { + return !this.completions.isEmpty() || this.handled; + } + + /** + * Sets whether to consider the completion request handled. + * If {@code true}, the standard process of calling {@link Command#tabComplete(CommandSender, String, String[])} + * or current player names will not be called. + * + * @param handled if this completion should be marked as being handled + */ + public void setHandled(final boolean handled) { + this.handled = handled; + } + + @Override + public boolean isCancelled() { + return this.cancelled; + } + + /** + * {@inheritDoc} + *
+ * Will provide no completions, and will not fire the synchronous process + */ + @Override + public void setCancelled(final boolean cancel) { + this.cancelled = cancel; + } + + @Override + public HandlerList getHandlers() { + return HANDLER_LIST; + } + + public static HandlerList getHandlerList() { + return HANDLER_LIST; + } + + private static List fromStrings(final List suggestions) { + final List list = new ArrayList<>(suggestions.size()); + for (final String suggestion : suggestions) { + list.add(new CompletionImpl(suggestion, null)); + } + return list; + } + + /** + * A rich tab completion, consisting of a string suggestion, and a nullable {@link Component} tooltip. + */ + public interface Completion extends Examinable { + + /** + * Get the suggestion string for this {@link Completion}. + * + * @return suggestion string + */ + String suggestion(); + + /** + * Get the suggestion tooltip for this {@link Completion}. + * + * @return tooltip component + */ + @Nullable Component tooltip(); + + @Override + default Stream examinableProperties() { + return Stream.of(ExaminableProperty.of("suggestion", this.suggestion()), ExaminableProperty.of("tooltip", this.tooltip())); + } + + /** + * Create a new {@link Completion} from a suggestion string. + * + * @param suggestion suggestion string + * @return new completion instance + */ + static Completion completion(final String suggestion) { + return new CompletionImpl(suggestion, null); + } + + /** + * Create a new {@link Completion} from a suggestion string and a tooltip {@link Component}. + *

+ * If the provided component is {@code null}, the suggestion will not have a tooltip. + * + * @param suggestion suggestion string + * @param tooltip tooltip component, or {@code null} + * @return new completion instance + */ + static Completion completion(final String suggestion, final @Nullable Component tooltip) { + return new CompletionImpl(suggestion, tooltip); + } + } + + @ApiStatus.Internal + static final class CompletionImpl implements Completion { + + private final String suggestion; + private final @Nullable Component tooltip; + + CompletionImpl(final String suggestion, final @Nullable Component tooltip) { + this.suggestion = suggestion; + this.tooltip = tooltip; + } + + @Override + public String suggestion() { + return this.suggestion; + } + + @Override + public @Nullable Component tooltip() { + return this.tooltip; + } + + @Override + public boolean equals(final @Nullable Object o) { + if (this == o) { + return true; + } + if (o == null || this.getClass() != o.getClass()) { + return false; + } + final CompletionImpl that = (CompletionImpl) o; + return this.suggestion.equals(that.suggestion) + && Objects.equals(this.tooltip, that.tooltip); + } + + @Override + public int hashCode() { + return Objects.hash(this.suggestion, this.tooltip); + } + + @Override + public String toString() { + return StringExaminer.simpleEscaping().examine(this); + } + } +} diff --git a/paper-api/src/main/java/io/papermc/paper/util/TransformingRandomAccessList.java b/paper-api/src/main/java/io/papermc/paper/util/TransformingRandomAccessList.java new file mode 100644 index 0000000000..488250fdcd --- /dev/null +++ b/paper-api/src/main/java/io/papermc/paper/util/TransformingRandomAccessList.java @@ -0,0 +1,172 @@ +package io.papermc.paper.util; + +import java.util.AbstractList; +import java.util.Iterator; +import java.util.List; +import java.util.ListIterator; +import java.util.RandomAccess; +import java.util.function.Function; +import java.util.function.Predicate; +import org.jetbrains.annotations.ApiStatus; +import org.jspecify.annotations.NullMarked; + +import static com.google.common.base.Preconditions.checkNotNull; + +/** + * Modified version of the Guava class with the same name to support add operations. + * + * @param backing list element type + * @param transformed list element type + */ +@NullMarked +@ApiStatus.Internal +public final class TransformingRandomAccessList extends AbstractList implements RandomAccess { + + final List fromList; + final Function toFunction; + final Function fromFunction; + + /** + * Create a new {@link TransformingRandomAccessList}. + * + * @param fromList backing list + * @param toFunction function mapping backing list element type to transformed list element type + * @param fromFunction function mapping transformed list element type to backing list element type + */ + public TransformingRandomAccessList( + final List fromList, + final Function toFunction, + final Function fromFunction + ) { + this.fromList = checkNotNull(fromList); + this.toFunction = checkNotNull(toFunction); + this.fromFunction = checkNotNull(fromFunction); + } + + @Override + public void clear() { + this.fromList.clear(); + } + + @Override + public T get(final int index) { + return this.toFunction.apply(this.fromList.get(index)); + } + + @Override + public Iterator iterator() { + return this.listIterator(); + } + + @Override + public ListIterator listIterator(final int index) { + return new TransformedListIterator<>(this.fromList.listIterator(index)) { + @Override + T transform(final F from) { + return TransformingRandomAccessList.this.toFunction.apply(from); + } + + @Override + F transformBack(final T from) { + return TransformingRandomAccessList.this.fromFunction.apply(from); + } + }; + } + + @Override + public boolean isEmpty() { + return this.fromList.isEmpty(); + } + + @Override + public boolean removeIf(final Predicate filter) { + checkNotNull(filter); + return this.fromList.removeIf(element -> filter.test(this.toFunction.apply(element))); + } + + @Override + public T remove(final int index) { + return this.toFunction.apply(this.fromList.remove(index)); + } + + @Override + public int size() { + return this.fromList.size(); + } + + @Override + public T set(final int i, final T t) { + return this.toFunction.apply(this.fromList.set(i, this.fromFunction.apply(t))); + } + + @Override + public void add(final int i, final T t) { + this.fromList.add(i, this.fromFunction.apply(t)); + } + + abstract static class TransformedListIterator implements ListIterator, Iterator { + + final Iterator backingIterator; + + TransformedListIterator(final ListIterator backingIterator) { + this.backingIterator = checkNotNull((Iterator) backingIterator); + } + + private ListIterator backingIterator() { + return cast(this.backingIterator); + } + + static ListIterator cast(final Iterator iterator) { + return (ListIterator) iterator; + } + + @Override + public final boolean hasPrevious() { + return this.backingIterator().hasPrevious(); + } + + @Override + public final T previous() { + return this.transform(this.backingIterator().previous()); + } + + @Override + public final int nextIndex() { + return this.backingIterator().nextIndex(); + } + + @Override + public final int previousIndex() { + return this.backingIterator().previousIndex(); + } + + @Override + public void set(final T element) { + this.backingIterator().set(this.transformBack(element)); + } + + @Override + public void add(final T element) { + this.backingIterator().add(this.transformBack(element)); + } + + abstract T transform(F from); + + abstract F transformBack(T to); + + @Override + public final boolean hasNext() { + return this.backingIterator.hasNext(); + } + + @Override + public final T next() { + return this.transform(this.backingIterator.next()); + } + + @Override + public final void remove() { + this.backingIterator.remove(); + } + } +} diff --git a/paper-api/src/main/java/org/bukkit/event/server/TabCompleteEvent.java b/paper-api/src/main/java/org/bukkit/event/server/TabCompleteEvent.java index 270e6d8ad4..6465e290c0 100644 --- a/paper-api/src/main/java/org/bukkit/event/server/TabCompleteEvent.java +++ b/paper-api/src/main/java/org/bukkit/event/server/TabCompleteEvent.java @@ -29,13 +29,20 @@ public class TabCompleteEvent extends Event implements Cancellable { private boolean cancelled; public TabCompleteEvent(@NotNull CommandSender sender, @NotNull String buffer, @NotNull List completions) { + // Paper start + this(sender, buffer, completions, sender instanceof org.bukkit.command.ConsoleCommandSender || buffer.startsWith("/"), null); + } + public TabCompleteEvent(@NotNull CommandSender sender, @NotNull String buffer, @NotNull List completions, boolean isCommand, @org.jetbrains.annotations.Nullable org.bukkit.Location location) { + this.isCommand = isCommand; + this.loc = location; + // Paper end Preconditions.checkArgument(sender != null, "sender"); Preconditions.checkArgument(buffer != null, "buffer"); Preconditions.checkArgument(completions != null, "completions"); this.sender = sender; this.buffer = buffer; - this.completions = completions; + this.completions = new java.util.ArrayList<>(completions); // Paper - Completions must be mutable } /** @@ -69,14 +76,35 @@ public class TabCompleteEvent extends Event implements Cancellable { return completions; } + // Paper start + private final boolean isCommand; + private final org.bukkit.Location loc; + /** + * @return True if it is a command being tab completed, false if it is a chat message. + */ + public boolean isCommand() { + return isCommand; + } + + /** + * @return The position looked at by the sender, or null if none + */ + @org.jetbrains.annotations.Nullable + public org.bukkit.Location getLocation() { + return this.loc != null ? this.loc.clone() : null; + } + // Paper end + /** * Set the completions offered, overriding any already set. * + * The passed collection will be cloned to a new List. You must call {{@link #getCompletions()}} to mutate from here + * * @param completions the new completions */ public void setCompletions(@NotNull List completions) { Preconditions.checkArgument(completions != null); - this.completions = completions; + this.completions = new java.util.ArrayList<>(completions); // Paper - completions must be mutable } @Override diff --git a/paper-api/src/test/java/org/bukkit/AnnotationTest.java b/paper-api/src/test/java/org/bukkit/AnnotationTest.java index 07f904a78f..5b0d26c68f 100644 --- a/paper-api/src/test/java/org/bukkit/AnnotationTest.java +++ b/paper-api/src/test/java/org/bukkit/AnnotationTest.java @@ -48,6 +48,8 @@ public class AnnotationTest { // Generic functional interface "org/bukkit/util/Consumer", // Paper start + "io/papermc/paper/util/TransformingRandomAccessList", + "io/papermc/paper/util/TransformingRandomAccessList$TransformedListIterator", // Timings history is broken in terms of nullability due to guavas Function defining that the param is NonNull "co/aikar/timings/TimingHistory$2", "co/aikar/timings/TimingHistory$2$1",