diff --git a/.gitignore b/.gitignore index b4c2a2e39..69e5ef25c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,22 +1,11 @@ /.gradle/ -/.idea/ -/build/ -/api/build/ -/api/out/ -/api/run/ -/bom/build/ /build-logic/.gradle/ -/build-logic/build/ -/extra-kotlin/build/ -/extra-kotlin/out/ -/key/build/ -/key/out/ -/nbt/build/ -/nbt/out/ -/run/ -/serializer-configurate*/build/ -/serializer-configurate*/out/ -/text-serializer-*/build/ -/text-serializer-*/out/ +/build/ +/*/build/ +/*/out/ +/*/run/ +/.idea/ /*.iml /*/.factorypath +/*/.settings/ +/*/.classpath diff --git a/bom/build.gradle.kts b/bom/build.gradle.kts index 0a51f366f..90e7633c4 100644 --- a/bom/build.gradle.kts +++ b/bom/build.gradle.kts @@ -18,6 +18,7 @@ dependencies { "nbt", "serializer-configurate3", "serializer-configurate4", + "text-minimessage", "text-serializer-gson", "text-serializer-gson-legacy-impl", "text-serializer-legacy", diff --git a/build-logic/src/main/kotlin/adventure.base-conventions.gradle.kts b/build-logic/src/main/kotlin/adventure.base-conventions.gradle.kts index 7011a5520..ae84cdc7c 100644 --- a/build-logic/src/main/kotlin/adventure.base-conventions.gradle.kts +++ b/build-logic/src/main/kotlin/adventure.base-conventions.gradle.kts @@ -36,6 +36,23 @@ indra { developer { id.set("Electroid") } + + developer { + id.set("minidigger") + name.set("MiniDigger") + } + + developer { + id.set("kezz") + } + + developer { + id.set("broccolai") + } + + developer { + id.set("rymiel") + } } } } diff --git a/build-logic/src/main/kotlin/adventure.common-conventions.gradle.kts b/build-logic/src/main/kotlin/adventure.common-conventions.gradle.kts index 6b23c14f6..1e8fee5fd 100644 --- a/build-logic/src/main/kotlin/adventure.common-conventions.gradle.kts +++ b/build-logic/src/main/kotlin/adventure.common-conventions.gradle.kts @@ -148,4 +148,14 @@ tasks { jacocoTestReport { dependsOn(test) } + + // Non-incremental + named("eclipseFactorypath", com.diffplug.gradle.eclipse.apt.GenerateEclipseFactorypath::class) { + doFirst { + val inFile = inputFile + if (inFile != null && inFile.exists()) { + inFile.delete() + } + } + } } diff --git a/settings.gradle.kts b/settings.gradle.kts index c764b5733..9d141af46 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -14,6 +14,7 @@ sequenceOf( "nbt", "serializer-configurate3", "serializer-configurate4", + "text-minimessage", "text-serializer-gson", "text-serializer-gson-legacy-impl", "text-serializer-legacy", diff --git a/text-minimessage/CHANGELOG.md b/text-minimessage/CHANGELOG.md new file mode 100644 index 000000000..1b90a2c5f --- /dev/null +++ b/text-minimessage/CHANGELOG.md @@ -0,0 +1,41 @@ +# Changelog + +# 4.1.0 (xx.11.2020) - the bye bye regex release + +* Rewrote the whole parser to use less regex and more formal grammar! +This means, that the whole parser is much more robust now. +* Added a transformation registry, to disable certain tags and add your own ones! +* Added placeholder resolvers, to better integrate placeholder apis! +* Added markdown flavors, to allow supporting multiple ones. DiscordFlavor for example supports ||sploilers||! +* Added a bunch of convenience aliases, `` becomes ``. For the full list, see the docs. +* Added ability to handle malformed input lenient or strict +* Slightly better escape handling (more work to come...) +* More unit tests! +* Updated adventure to 4.2 + +This doesn't even look that much, now that I wrote this all down, but this release has over three thousand new lines and almost one thousand deleted lines! +This small lib contains now almost five thousand lines of code and one hundred unit tests! + +# 4.0.0 (xx.07.2020) - the lost release + +* MiniMessage is now part of the Kyori organization! +* Add support for hex colors, rainbows and gradients +* Add templates, a new type of placeholder that works with components +* Dropped bungee impl +* Moved markdown ext into the main project +* Refactored the project, MiniMessage is now the main api +* Moved docs to the adventure docs page https://docs.adventure.kyori.net/minimessage.html +* Moved the project to gradle to align with other kyori projects + +# 2.1.0 (12.06.2020) - the rgb release + +* Move packages around to avoid confusion between bungee and text impls +* Allow the usage of single quotes to define inner components +* Use localized toLower/Uppercase methods (@mikroskeem) +* Use map lookup over valueOf when resolving stuff (@mikroskeem) +* Add new color tag syntax (in preparation for 1.16 hex colors) (@mikroskeem) +* Support reset tags +* Support pre tags +* Support placeholders in lang tags +* Add [docs](/DOCS.md) +* Add changelog (this, lol) diff --git a/text-minimessage/build.gradle.kts b/text-minimessage/build.gradle.kts new file mode 100644 index 000000000..caf2be992 --- /dev/null +++ b/text-minimessage/build.gradle.kts @@ -0,0 +1,17 @@ +plugins { + id("adventure.common-conventions") + id("me.champeau.jmh") +} + +description = "A string-based, user-friendly format for representing Minecraft: Java Edition chat components." + +dependencies { + api(project(":adventure-api")) + testImplementation(project(":adventure-text-serializer-plain")) +} + +tasks.checkstyleJmh { + exclude("**") +} + +applyJarMetadata("net.kyori.adventure.text.minimessage") diff --git a/text-minimessage/src/jmh/java/net/kyori/adventure/text/minimessage/benchmark/MiniMessageBenchmark.java b/text-minimessage/src/jmh/java/net/kyori/adventure/text/minimessage/benchmark/MiniMessageBenchmark.java new file mode 100644 index 000000000..9ce40e3cb --- /dev/null +++ b/text-minimessage/src/jmh/java/net/kyori/adventure/text/minimessage/benchmark/MiniMessageBenchmark.java @@ -0,0 +1,64 @@ +/* + * This file is part of adventure, licensed under the MIT License. + * + * Copyright (c) 2017-2021 KyoriPowered + * + * 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 net.kyori.adventure.text.minimessage.benchmark; + +import java.util.concurrent.TimeUnit; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.minimessage.MiniMessage; +import net.kyori.adventure.text.minimessage.placeholder.PlaceholderResolver; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; + +@Fork(value = 1, warmups = 1) +@BenchmarkMode(Mode.SampleTime) +@OutputTimeUnit(TimeUnit.MICROSECONDS) +public class MiniMessageBenchmark { + + @Benchmark + public Component testNiceMix() { + final String input = " random strangerclick here to FEEL it"; + return MiniMessage.miniMessage().parse(input); + } + + @Benchmark + public Component testSimple() { + final String input = "stranger"; + return MiniMessage.miniMessage().deserialize(input, PlaceholderResolver.resolving("test", "test2")); + } + + @Benchmark + public Component testGradient() { + final String input = "COLORS ARE COOL"; + return MiniMessage.miniMessage().parse(input); + } + + @Benchmark + public Component testRainbow() { + final String input = "COLORS ARE COOL"; + return MiniMessage.miniMessage().parse(input); + } +} diff --git a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/Context.java b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/Context.java new file mode 100644 index 000000000..7b9df7652 --- /dev/null +++ b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/Context.java @@ -0,0 +1,257 @@ +/* + * This file is part of adventure, licensed under the MIT License. + * + * Copyright (c) 2017-2021 KyoriPowered + * + * 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 net.kyori.adventure.text.minimessage; + +import java.util.function.UnaryOperator; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.minimessage.parser.node.ElementNode; +import net.kyori.adventure.text.minimessage.placeholder.Placeholder; +import net.kyori.adventure.text.minimessage.placeholder.PlaceholderResolver; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Carries needed context for minimessage around, ranging from debug info to the configured minimessage instance. + * + * @since 4.10.0 + */ +public class Context { + private final boolean strict; + private final Appendable debugOutput; + private ElementNode root; + private final String originalMessage; + private String replacedMessage; + private final MiniMessage miniMessage; + private final PlaceholderResolver placeholderResolver; + private final UnaryOperator postProcessor; + + Context(final boolean strict, final Appendable debugOutput, final ElementNode root, final String originalMessage, final String replacedMessage, final MiniMessage miniMessage, final @NotNull PlaceholderResolver placeholderResolver, final UnaryOperator postProcessor) { + this.strict = strict; + this.debugOutput = debugOutput; + this.root = root; + this.originalMessage = originalMessage; + this.replacedMessage = replacedMessage; + this.miniMessage = miniMessage; + this.placeholderResolver = placeholderResolver; + this.postProcessor = postProcessor == null ? UnaryOperator.identity() : postProcessor; + } + + /** + * Init. + * + * @param strict if strict mode is enabled + * @param input the input message + * @param miniMessage the minimessage instance + * @return the debug context + * @since 4.10.0 + */ + public static Context of(final boolean strict, final String input, final MiniMessage miniMessage) { + return new Context(strict, null, null, input, null, miniMessage, PlaceholderResolver.empty(), null); + } + + /** + * Init. + * + * @param strict if strict mode is enabled + * @param debugOutput where to print debug output + * @param input the input message + * @param miniMessage the minimessage instance + * @return the debug context + * @since 4.10.0 + */ + public static Context of(final boolean strict, final Appendable debugOutput, final String input, final MiniMessage miniMessage) { + return new Context(strict, debugOutput, null, input, null, miniMessage, PlaceholderResolver.empty(), null); + } + + /** + * Init. + * + * @param strict if strict mode is enabled + * @param input the input message + * @param miniMessage the minimessage instance + * @param placeholders the placeholders passed to minimessage + * @return the debug context + * @since 4.10.0 + */ + public static Context of(final boolean strict, final String input, final MiniMessageImpl miniMessage, @NotNull final Placeholder @Nullable [] placeholders) { + return new Context(strict, null, null, input, null, miniMessage, placeholders == null ? PlaceholderResolver.empty() : PlaceholderResolver.placeholders(placeholders), null); + } + + /** + * Init. + * + * @param strict if strict mode is enabled + * @param debugOutput where to print debug output + * @param input the input message + * @param miniMessage the minimessage instance + * @param placeholders the placeholders passed to minimessage + * @return the debug context + * @since 4.10.0 + */ + public static Context of(final boolean strict, final Appendable debugOutput, final String input, final MiniMessageImpl miniMessage, @NotNull final Placeholder @Nullable [] placeholders) { + return new Context(strict, debugOutput, null, input, null, miniMessage, placeholders == null ? PlaceholderResolver.empty() : PlaceholderResolver.placeholders(placeholders), null); + } + + /** + * Init. + * + * @param strict if strict mode is enabled + * @param debugOutput where to print debug output + * @param input the input message + * @param miniMessage the minimessage instance + * @param placeholderResolver the placeholder resolver passed to minimessage + * @return the debug context + * @since 4.10.0 + */ + public static Context of(final boolean strict, final Appendable debugOutput, final String input, final MiniMessageImpl miniMessage, final PlaceholderResolver placeholderResolver) { + return new Context(strict, debugOutput, null, input, null, miniMessage, placeholderResolver, null); + } + + /** + * Init. + * + * @param strict if strict mode is enabled + * @param debugOutput where to print debug output + * @param input the input message + * @param miniMessage the minimessage instance + * @param placeholderResolver the placeholder resolver passed to minimessage + * @param postProcessor callback ran at the end of parsing which could be used to compact the output + * @return the debug context + * @since 4.10.0 + */ + public static Context of(final boolean strict, final Appendable debugOutput, final String input, final MiniMessageImpl miniMessage, final PlaceholderResolver placeholderResolver, final UnaryOperator postProcessor) { + return new Context(strict, debugOutput, null, input, null, miniMessage, placeholderResolver, postProcessor); + } + + /** + * Sets the root element. + * + * @param root the root element. + * @since 4.10.0 + */ + public void root(final ElementNode root) { + this.root = root; + } + + /** + * sets the replaced message. + * + * @param replacedMessage the replaced message + * @since 4.10.0 + */ + public void replacedMessage(final String replacedMessage) { + this.replacedMessage = replacedMessage; + } + + /** + * Returns strict mode. + * + * @return if strict mode is enabled + * @since 4.10.0 + */ + public boolean strict() { + return this.strict; + } + + /** + * Returns the appendable to print debug output to. + * + * @return the debug output to print to + * @since 4.10.0 + */ + public Appendable debugOutput() { + return this.debugOutput; + } + + /** + * Returns the root element. + * + * @return root + * @since 4.10.0 + */ + public ElementNode tokens() { + return this.root; + } + + /** + * Returns original message. + * + * @return ogMessage + * @since 4.10.0 + */ + public String originalMessage() { + return this.originalMessage; + } + + /** + * Returns replaced message. + * + * @return replacedMessage + * @since 4.10.0 + */ + public String replacedMessage() { + return this.replacedMessage; + } + + /** + * Returns minimessage. + * + * @return minimessage + * @since 4.10.0 + */ + public MiniMessage miniMessage() { + return this.miniMessage; + } + + /** + * Returns the placeholder resolver. + * + * @return the placeholder resolver + * @since 4.10.0 + */ + public @NotNull PlaceholderResolver placeholderResolver() { + return this.placeholderResolver; + } + + /** + * Returns callback ran at the end of parsing which could be used to compact the output. + * + * @return Post-processing function + * @since 4.10.0 + */ + public UnaryOperator postProcessor() { + return this.postProcessor; + } + + /** + * Parses a MiniMessage using all the settings of this context, including placeholders. + * + * @param message the message to parse + * @return the parsed message + * @since 4.10.0 + */ + public Component parse(final String message) { + return this.miniMessage.deserialize(message, this.placeholderResolver); + } +} diff --git a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/MiniMessage.java b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/MiniMessage.java new file mode 100644 index 000000000..9a63ec09f --- /dev/null +++ b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/MiniMessage.java @@ -0,0 +1,231 @@ +/* + * This file is part of adventure, licensed under the MIT License. + * + * Copyright (c) 2017-2021 KyoriPowered + * + * 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 net.kyori.adventure.text.minimessage; + +import java.util.List; +import java.util.function.Consumer; +import java.util.function.UnaryOperator; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.minimessage.placeholder.PlaceholderResolver; +import net.kyori.adventure.text.minimessage.transformation.TransformationRegistry; +import net.kyori.adventure.text.serializer.ComponentSerializer; +import net.kyori.adventure.util.Buildable; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * MiniMessage is a textual representation of components. + * + *

This class allows you to serialize and deserialize them, strip + * or escape them.

+ * + * @since 4.10.0 + */ +public interface MiniMessage extends ComponentSerializer, Buildable { + + /** + * Gets a simple instance without markdown support. + * + * @return a simple instance + * @since 4.10.0 + */ + static @NotNull MiniMessage miniMessage() { + return MiniMessageImpl.INSTANCE; + } + + /** + * Escapes all known tokens in the input message, so that they are ignored in deserialization. + * + *

Useful for untrusted input.

+ * + *

Only globally known tokens will be escaped. Use the overload that takes a {@link PlaceholderResolver} if placeholders should be handled.

+ * + * @param input the input message, with tokens + * @return the output, with escaped tokens + * @since 4.10.0 + */ + @NotNull String escapeTokens(final @NotNull String input); + + /** + * Escapes all known tokens in the input message, so that they are ignored in deserialization. + * + *

Useful for untrusted input.

+ * + * @param input the input message, with tokens + * @param placeholders the placeholder resolver adding known placeholders + * @return the output, with escaped tokens + * @since 4.10.0 + */ + @NotNull String escapeTokens(final @NotNull String input, final @NotNull PlaceholderResolver placeholders); + + /** + * Removes all supported tokens in the input message. + * + *

Useful for untrusted input.

+ * + *

Only globally known tokens will be stripped. Use the overload that takes a {@link PlaceholderResolver} if placeholders should be handled.

+ * + * @param input the input message, with tokens + * @return the output, without tokens + * @since 4.10.0 + */ + @NotNull String stripTokens(final @NotNull String input); + + /** + * Removes all known tokens in the input message, so that they are ignored in deserialization. + * + *

Useful for untrusted input.

+ * + * @param input the input message, with tokens + * @param placeholders the placeholder resolver adding known placeholders + * @return the output, without tokens + * @since 4.10.0 + */ + @NotNull String stripTokens(final @NotNull String input, final @NotNull PlaceholderResolver placeholders); + + /** + * Parses a string into an component. + * + * @param input the input string + * @return the output component + * @since 4.10.0 + */ + default Component parse(final @NotNull String input) { + return this.deserialize(input); + } + + /** + * Deserializes a string into a component, with a placeholder resolver to parse placeholders of the form {@code }. + * + *

Placeholders will be resolved from this resolver before the resolver provided in the builder is used.

+ * + * @param input the input string + * @param placeholderResolver the placeholder resolver + * @return the output component + * @since 4.10.0 + */ + @NotNull Component deserialize(final @NotNull String input, final @NotNull PlaceholderResolver placeholderResolver); + + /** + * Creates a new {@link MiniMessage.Builder}. + * + * @return a builder + * @since 4.10.0 + */ + static Builder builder() { + return new MiniMessageImpl.BuilderImpl(); + } + + /** + * A builder for {@link MiniMessage}. + * + * @since 4.10.0 + */ + interface Builder extends Buildable.Builder { + + /** + * Uses the supplied transformation registry. + * + * @param transformationRegistry the transformation registry to use + * @return this builder + * @since 4.10.0 + */ + @NotNull Builder transformations(final @NotNull TransformationRegistry transformationRegistry); + + /** + * Modify the set transformation registry. + * + *

By default, this will start out with a registry of all default transformations.

+ * + * @param modifier an action to perform on the registry builder + * @return this builder + * @since 4.10.0 + */ + @NotNull Builder transformations(final @NotNull Consumer modifier); + + /** + * Sets the placeholder resolver. + * + *

This placeholder resolver will be used after any placeholder resolved provided in {@link MiniMessage#deserialize(String, PlaceholderResolver)}.

+ * + * @param placeholderResolver the placeholder resolver to use, if any + * @return this builder + * @since 4.10.0 + */ + @NotNull Builder placeholderResolver(final @Nullable PlaceholderResolver placeholderResolver); + + /** + * Allows to enable strict mode (disabled by default). + * + *

By default, MiniMessage will allow non-{@link net.kyori.adventure.text.minimessage.transformation.Inserting Inserting} tags to be implicitly closed. When strict mode + * is enabled, all non-inserting tags which are {@code } must be explicitly {@code } as well.

+ * + * @param strict if strict mode should be enabled + * @return this builder + * @since 4.10.0 + */ + @NotNull Builder strict(final boolean strict); + + /** + * Print debug information to the given output (disabled by default). + * + *

Debug output includes detailed information about the parsing process to help debug parser behavior.

+ * + * @param debugOutput if debug mode should be enabled + * @return this builder + * @since 4.10.0 + */ + @NotNull Builder debug(final @Nullable Appendable debugOutput); + + /** + * If in lenient mode, MiniMessage will output helpful messages. + * + *

This method allows you to change how they should be printed. By default, they will be printed to standard out.

+ * + * @param consumer the error message consumer + * @return this builder + * @since 4.10.0 + */ + @NotNull Builder parsingErrorMessageConsumer(final @NotNull Consumer> consumer); + + /** + * Specify a function that takes the component at the end of the parser process. + *

By default, this compacts the resulting component with {@link Component#compact()}.

+ * + * @param postProcessor method run at the end of parsing + * @return this builder + * @since 4.10.0 + */ + @NotNull Builder postProcessor(final @NotNull UnaryOperator postProcessor); + + /** + * Builds the serializer. + * + * @return the built serializer + * @since 4.10.0 + */ + @Override + @NotNull MiniMessage build(); + } +} diff --git a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/MiniMessageImpl.java b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/MiniMessageImpl.java new file mode 100644 index 000000000..c86b999da --- /dev/null +++ b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/MiniMessageImpl.java @@ -0,0 +1,189 @@ +/* + * This file is part of adventure, licensed under the MIT License. + * + * Copyright (c) 2017-2021 KyoriPowered + * + * 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 net.kyori.adventure.text.minimessage; + +import java.util.List; +import java.util.Objects; +import java.util.function.Consumer; +import java.util.function.UnaryOperator; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.minimessage.placeholder.PlaceholderResolver; +import net.kyori.adventure.text.minimessage.transformation.TransformationRegistry; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import static java.util.Objects.requireNonNull; + +/** + * not public api. + * + * @since 4.10.0 + */ +final class MiniMessageImpl implements MiniMessage { + static final Consumer> DEFAULT_ERROR_CONSUMER = message -> message.forEach(System.out::println); + static final UnaryOperator DEFAULT_COMPACTING_METHOD = Component::compact; + + static final MiniMessage INSTANCE = new MiniMessageImpl(TransformationRegistry.standard(), PlaceholderResolver.empty(), false, null, DEFAULT_ERROR_CONSUMER, DEFAULT_COMPACTING_METHOD); + + private final boolean strict; + private final Appendable debugOutput; + private final Consumer> parsingErrorMessageConsumer; + private final UnaryOperator postProcessor; + final MiniMessageParser parser; + + MiniMessageImpl(final @NotNull TransformationRegistry registry, final @NotNull PlaceholderResolver placeholderResolver, final boolean strict, final Appendable debugOutput, final @NotNull Consumer> parsingErrorMessageConsumer, final @NotNull UnaryOperator postProcessor) { + this.parser = new MiniMessageParser(registry, placeholderResolver); + this.strict = strict; + this.debugOutput = debugOutput; + this.parsingErrorMessageConsumer = parsingErrorMessageConsumer; + this.postProcessor = postProcessor; + } + + @Override + public @NotNull Component deserialize(final @NotNull String input) { + return this.parser.parseFormat(input, this.newContext(input, null)); + } + + @Override + public @NotNull Component deserialize(final @NotNull String input, final @NotNull PlaceholderResolver placeholderResolver) { + return this.parser.parseFormat(input, this.newContext(input, requireNonNull(placeholderResolver, "placeholderResolver"))); + } + + @Override + public @NotNull String serialize(final @NotNull Component component) { + return MiniMessageSerializer.serialize(component); + } + + @Override + public @NotNull String escapeTokens(final @NotNull String input) { + return this.parser.escapeTokens(input, this.newContext(input, null)); + } + + @Override + public @NotNull String escapeTokens(@NotNull final String input, @NotNull final PlaceholderResolver placeholders) { + return this.parser.escapeTokens(input, this.newContext(input, placeholders)); + } + + @Override + public @NotNull String stripTokens(final @NotNull String input) { + return this.parser.stripTokens(input, this.newContext(input, null)); + } + + @Override + public @NotNull String stripTokens(@NotNull final String input, @NotNull final PlaceholderResolver placeholders) { + return this.parser.stripTokens(input, this.newContext(input, placeholders)); + } + + private @NotNull Context newContext(final @NotNull String input, final @Nullable PlaceholderResolver resolver) { + if (resolver == null) { + return Context.of(this.strict, this.debugOutput, input, this, PlaceholderResolver.empty(), this.postProcessor); + } else { + return Context.of(this.strict, this.debugOutput, input, this, resolver, this.postProcessor); + } + } + + /** + * not public api. + * + * @return huhu. + * @since 4.10.0 + */ + public @NotNull Consumer> parsingErrorMessageConsumer() { + return this.parsingErrorMessageConsumer; + } + + @Override + public @NotNull Builder toBuilder() { + return new BuilderImpl(this); + } + + static final class BuilderImpl implements Builder { + private TransformationRegistry registry = TransformationRegistry.standard(); + private PlaceholderResolver placeholderResolver = null; + private boolean strict = false; + private Appendable debug = null; + private Consumer> parsingErrorMessageConsumer = DEFAULT_ERROR_CONSUMER; + private UnaryOperator postProcessor = DEFAULT_COMPACTING_METHOD; + + BuilderImpl() { + } + + BuilderImpl(final MiniMessageImpl serializer) { + this.registry = serializer.parser.registry; + this.placeholderResolver = serializer.parser.placeholderResolver; + this.strict = serializer.strict; + this.debug = serializer.debugOutput; + this.parsingErrorMessageConsumer = serializer.parsingErrorMessageConsumer; + } + + @Override + public @NotNull Builder transformations(final @NotNull TransformationRegistry transformationRegistry) { + this.registry = requireNonNull(transformationRegistry, "transformationRegistry"); + return this; + } + + @Override + public @NotNull Builder transformations(final @NotNull Consumer modifier) { + final TransformationRegistry.Builder builder = this.registry.toBuilder(); + modifier.accept(builder); + this.registry = builder.build(); + return this; + } + + @Override + public @NotNull Builder placeholderResolver(final @Nullable PlaceholderResolver placeholderResolver) { + this.placeholderResolver = placeholderResolver; + return this; + } + + @Override + public @NotNull Builder strict(final boolean strict) { + this.strict = strict; + return this; + } + + @Override + public @NotNull Builder debug(final @Nullable Appendable debugOutput) { + this.debug = debugOutput; + return this; + } + + @Override + public @NotNull Builder parsingErrorMessageConsumer(final @NotNull Consumer> consumer) { + this.parsingErrorMessageConsumer = requireNonNull(consumer, "consumer"); + return this; + } + + @Override + public @NotNull Builder postProcessor(final @NotNull UnaryOperator postProcessor) { + this.postProcessor = Objects.requireNonNull(postProcessor, "postProcessor"); + return this; + } + + @Override + public @NotNull MiniMessage build() { + return new MiniMessageImpl(this.registry, this.placeholderResolver == null ? PlaceholderResolver.empty() : this.placeholderResolver, this.strict, this.debug, this.parsingErrorMessageConsumer, this.postProcessor); + } + } +} diff --git a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/MiniMessageParser.java b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/MiniMessageParser.java new file mode 100644 index 000000000..a24cb9eef --- /dev/null +++ b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/MiniMessageParser.java @@ -0,0 +1,251 @@ +/* + * This file is part of adventure, licensed under the MIT License. + * + * Copyright (c) 2017-2021 KyoriPowered + * + * 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 net.kyori.adventure.text.minimessage; + +import java.io.IOException; +import java.util.LinkedList; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.function.BiConsumer; +import java.util.function.BiPredicate; +import java.util.function.Function; +import java.util.stream.Collectors; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.minimessage.parser.ParsingException; +import net.kyori.adventure.text.minimessage.parser.Token; +import net.kyori.adventure.text.minimessage.parser.TokenParser; +import net.kyori.adventure.text.minimessage.parser.TokenType; +import net.kyori.adventure.text.minimessage.parser.node.ElementNode; +import net.kyori.adventure.text.minimessage.parser.node.TagNode; +import net.kyori.adventure.text.minimessage.parser.node.ValueNode; +import net.kyori.adventure.text.minimessage.placeholder.PlaceholderResolver; +import net.kyori.adventure.text.minimessage.transformation.Modifying; +import net.kyori.adventure.text.minimessage.transformation.Transformation; +import net.kyori.adventure.text.minimessage.transformation.TransformationRegistry; +import net.kyori.examination.string.MultiLineStringExaminer; +import org.jetbrains.annotations.NotNull; + +final class MiniMessageParser { + final TransformationRegistry registry; + final PlaceholderResolver placeholderResolver; + + MiniMessageParser() { + this.registry = TransformationRegistry.standard(); + this.placeholderResolver = PlaceholderResolver.empty(); + } + + MiniMessageParser(final TransformationRegistry registry, final PlaceholderResolver placeholderResolver) { + this.registry = registry; + this.placeholderResolver = placeholderResolver; + } + + @NotNull String escapeTokens(final @NotNull String richMessage, final @NotNull Context context) { + final StringBuilder sb = new StringBuilder(richMessage.length()); + this.escapeTokens(sb, richMessage, context); + return sb.toString(); + } + + void escapeTokens(final StringBuilder sb, final @NotNull String richMessage, final @NotNull Context context) { + this.processTokens(sb, richMessage, context, (token, builder) -> { + builder.append('\\').append(Tokens.TAG_START); + if (token.type() == TokenType.CLOSE_TAG) { + builder.append(Tokens.CLOSE_TAG); + } + final List childTokens = token.childTokens(); + for (int i = 0; i < childTokens.size(); i++) { + if (i != 0) { + builder.append(Tokens.SEPARATOR); + } + this.escapeTokens(builder, childTokens.get(i).get(richMessage).toString(), context); // todo: do we need to unwrap quotes on this? + } + builder.append(Tokens.TAG_END); + }); + } + + @NotNull String stripTokens(final @NotNull String richMessage, final @NotNull Context context) { + final StringBuilder sb = new StringBuilder(richMessage.length()); + this.processTokens(sb, richMessage, context, (token, builder) -> {}); + return sb.toString(); + } + + private void processTokens(final @NotNull StringBuilder sb, final @NotNull String richMessage, final @NotNull Context context, final BiConsumer tagHandler) { + final PlaceholderResolver combinedResolver = PlaceholderResolver.combining(context.placeholderResolver(), this.placeholderResolver); + final List root = TokenParser.tokenize(richMessage); + for (final Token token : root) { + switch (token.type()) { + case TEXT: + sb.append(richMessage, token.startIndex(), token.endIndex()); + break; + case OPEN_TAG: + case CLOSE_TAG: + // extract tag name + if (token.childTokens().isEmpty()) { + sb.append(richMessage, token.startIndex(), token.endIndex()); + continue; + } + final String sanitized = this.sanitizePlaceholderName(token.childTokens().get(0).get(richMessage).toString()); + if (this.registry.exists(sanitized, combinedResolver) || combinedResolver.canResolve(sanitized)) { + tagHandler.accept(token, sb); + } else { + sb.append(richMessage, token.startIndex(), token.endIndex()); + } + break; + default: + throw new IllegalArgumentException("Unsupported token type " + token.type()); + } + } + } + + @NotNull Component parseFormat(final @NotNull String richMessage, final @NotNull Context context) { + final PlaceholderResolver combinedResolver = PlaceholderResolver.combining(context.placeholderResolver(), this.placeholderResolver); + final Appendable debug = context.debugOutput(); + if (debug != null) { + try { + debug.append("Beginning parsing message ").append(richMessage).append('\n'); + } catch (final IOException ignored) { + } + } + + final Function transformationFactory; + if (debug != null) { + transformationFactory = node -> { + try { + try { + debug.append("Attempting to match node '").append(node.name()).append("' at column ") + .append(String.valueOf(node.token().startIndex())).append('\n'); + } catch (final IOException ignored) { + } + + final Transformation transformation = this.registry.get(this.sanitizePlaceholderName(node.name()), node.parts(), combinedResolver, context); + + try { + if (transformation == null) { + debug.append("Could not match node '").append(node.name()).append("'\n"); + } else { + debug.append("Successfully matched node '").append(node.name()).append("' to transformation ") + .append(transformation.examinableName()).append('\n'); + } + } catch (final IOException ignored) { + } + + return transformation; + } catch (final ParsingException e) { + try { + if (e.tokens().length == 0) { + e.tokens(new Token[]{node.token()}); + } + debug.append("Could not match node '").append(node.name()).append("' - ").append(e.getMessage()).append('\n'); + } catch (final IOException ignored) { + } + return null; + } + }; + } else { + transformationFactory = node -> { + try { + return this.registry.get(this.sanitizePlaceholderName(node.name()), node.parts(), combinedResolver, context); + } catch (final ParsingException ignored) { + return null; + } + }; + } + final BiPredicate tagNameChecker = (name, includePlaceholders) -> { + final String sanitized = this.sanitizePlaceholderName(name); + return this.registry.exists(sanitized) || (includePlaceholders && combinedResolver.canResolve(name)); + }; + + final ElementNode root = TokenParser.parse(transformationFactory, tagNameChecker, combinedResolver, richMessage, context.strict()); + + if (debug != null) { + try { + debug.append("Text parsed into element tree:\n"); + debug.append(root.toString()); + } catch (final IOException ignored) { + } + } + + context.root(root); + return Objects.requireNonNull(context.postProcessor().apply(this.treeToComponent(root, context)), "Post-processor must not return null"); + } + + @NotNull Component treeToComponent(final @NotNull ElementNode node, final @NotNull Context context) { + Component comp; + Transformation transformation = null; + if (node instanceof ValueNode) { + comp = Component.text(((ValueNode) node).value()); + } else if (node instanceof TagNode) { + final TagNode tag = (TagNode) node; + + transformation = tag.transformation(); + + // special case for gradient and stuff + if (transformation instanceof Modifying) { + final Modifying modTransformation = (Modifying) transformation; + + // first walk the tree + final LinkedList toVisit = new LinkedList<>(node.children()); + while (!toVisit.isEmpty()) { + final ElementNode curr = toVisit.removeFirst(); + modTransformation.visit(curr); + toVisit.addAll(0, curr.children()); + } + } + comp = transformation.apply(); + } else { + comp = Component.empty(); + } + + for (final ElementNode child : node.children()) { + comp = comp.append(this.treeToComponent(child, context)); + } + + // special case for gradient and stuff + if (transformation instanceof Modifying) { + comp = this.handleModifying((Modifying) transformation, comp, 0); + } + + final Appendable debug = context.debugOutput(); + if (debug != null) { + try { + debug.append("==========\ntreeToComponent \n").append(node.toString()).append("\n").append(comp.examine(MultiLineStringExaminer.simpleEscaping()).collect(Collectors.joining("\n"))).append("\n==========\n"); + } catch (final IOException ignored) { + } + } + + return comp; + } + + private Component handleModifying(final Modifying modTransformation, final Component current, final int depth) { + Component newComp = modTransformation.apply(current, depth); + for (final Component child : current.children()) { + newComp = newComp.append(this.handleModifying(modTransformation, child, depth + 1)); + } + return newComp; + } + + private String sanitizePlaceholderName(final String name) { + return name.toLowerCase(Locale.ROOT); + } +} diff --git a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/MiniMessageSerializer.java b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/MiniMessageSerializer.java new file mode 100644 index 000000000..e40078041 --- /dev/null +++ b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/MiniMessageSerializer.java @@ -0,0 +1,360 @@ +/* + * This file is part of adventure, licensed under the MIT License. + * + * Copyright (c) 2017-2021 KyoriPowered + * + * 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 net.kyori.adventure.text.minimessage; + +import java.util.LinkedList; +import java.util.List; +import java.util.Objects; +import net.kyori.adventure.key.Key; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.KeybindComponent; +import net.kyori.adventure.text.TextComponent; +import net.kyori.adventure.text.TranslatableComponent; +import net.kyori.adventure.text.event.ClickEvent; +import net.kyori.adventure.text.event.HoverEvent; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.format.Style; +import net.kyori.adventure.text.format.TextColor; +import net.kyori.adventure.text.format.TextDecoration; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import static net.kyori.adventure.text.minimessage.Tokens.BOLD; +import static net.kyori.adventure.text.minimessage.Tokens.CLICK; +import static net.kyori.adventure.text.minimessage.Tokens.CLOSE_TAG; +import static net.kyori.adventure.text.minimessage.Tokens.COLOR; +import static net.kyori.adventure.text.minimessage.Tokens.FONT; +import static net.kyori.adventure.text.minimessage.Tokens.HOVER; +import static net.kyori.adventure.text.minimessage.Tokens.INSERTION; +import static net.kyori.adventure.text.minimessage.Tokens.ITALIC; +import static net.kyori.adventure.text.minimessage.Tokens.KEYBIND; +import static net.kyori.adventure.text.minimessage.Tokens.OBFUSCATED; +import static net.kyori.adventure.text.minimessage.Tokens.SEPARATOR; +import static net.kyori.adventure.text.minimessage.Tokens.STRIKETHROUGH; +import static net.kyori.adventure.text.minimessage.Tokens.TAG_END; +import static net.kyori.adventure.text.minimessage.Tokens.TAG_START; +import static net.kyori.adventure.text.minimessage.Tokens.TRANSLATABLE; +import static net.kyori.adventure.text.minimessage.Tokens.UNDERLINED; + +final class MiniMessageSerializer { + private MiniMessageSerializer() { + } + + static @NotNull String serialize(final @NotNull Component component) { + final List nodes = traverseNode(new ComponentNode(component)); + final StringBuilder sb = new StringBuilder(); + + for (int i = 0; i < nodes.size(); i++) { + // The previous node, null if it doesn't exist. + final Style previous = ((i - 1) >= 0) ? nodes.get(i - 1).style() : null; + + // The next node, null if it doesn't exist. + final Style next = ((i + 1) < nodes.size()) ? nodes.get(i + 1).style() : null; + + // The current node. + final ComponentNode node = nodes.get(i); + + // Serialized string for the node. + sb.append(serializeNode(node, previous, next)); + } + return sb.toString(); + } + + // Sorts a ComponentNode's tree in a LinkedList using Pre Order Traversal. + private static List traverseNode(@NotNull final ComponentNode root) { + final List nodes = new LinkedList<>(); + nodes.add(root); + + // Only continue if children found. + if (!root.component().children().isEmpty()) { + for (final Component child : root.component().children()) { + nodes.addAll(traverseNode(new ComponentNode(child, root.style()))); + } + } + + return nodes; + } + + // Serializes a single node into minimessage format. + private static String serializeNode(@NotNull final ComponentNode node, @Nullable final Style previous, @Nullable final Style next) { + final StringBuilder sb = new StringBuilder(); + final Style style = node.style(); + + // # start tags + + // ## color + if (style.color() != null && (previous == null || previous.color() != style.color())) { + sb.append(startColor(Objects.requireNonNull(style.color()))); + } + + // ## decoration + // ### only start if previous didn't start + if (style.hasDecoration(TextDecoration.BOLD) && (previous == null || !previous.hasDecoration(TextDecoration.BOLD))) { + sb.append(startTag(BOLD)); + } + if (style.hasDecoration(TextDecoration.ITALIC) && (previous == null || !previous.hasDecoration(TextDecoration.ITALIC))) { + sb.append(startTag(ITALIC)); + } + if (style.hasDecoration(TextDecoration.OBFUSCATED) && (previous == null || !previous.hasDecoration(TextDecoration.OBFUSCATED))) { + sb.append(startTag(OBFUSCATED)); + } + if (style.hasDecoration(TextDecoration.STRIKETHROUGH) && (previous == null || !previous.hasDecoration(TextDecoration.STRIKETHROUGH))) { + sb.append(startTag(STRIKETHROUGH)); + } + if (style.hasDecoration(TextDecoration.UNDERLINED) && (previous == null || !previous.hasDecoration(TextDecoration.UNDERLINED))) { + sb.append(startTag(UNDERLINED)); + } + + // ## disabled decorations + // ### only start if previous didn't start + if (style.decoration(TextDecoration.BOLD) == TextDecoration.State.FALSE && (previous == null || previous.decoration(TextDecoration.BOLD) == TextDecoration.State.NOT_SET)) { + sb.append(startTag("!" + BOLD)); + } + if (style.decoration(TextDecoration.ITALIC) == TextDecoration.State.FALSE && (previous == null || previous.decoration(TextDecoration.ITALIC) == TextDecoration.State.NOT_SET)) { + sb.append(startTag("!" + ITALIC)); + } + if (style.decoration(TextDecoration.OBFUSCATED) == TextDecoration.State.FALSE && (previous == null || previous.decoration(TextDecoration.OBFUSCATED) == TextDecoration.State.NOT_SET)) { + sb.append(startTag("!" + OBFUSCATED)); + } + if (style.decoration(TextDecoration.STRIKETHROUGH) == TextDecoration.State.FALSE && (previous == null || previous.decoration(TextDecoration.STRIKETHROUGH) == TextDecoration.State.NOT_SET)) { + sb.append(startTag("!" + STRIKETHROUGH)); + } + if (style.decoration(TextDecoration.UNDERLINED) == TextDecoration.State.FALSE && (previous == null || previous.decoration(TextDecoration.UNDERLINED) == TextDecoration.State.NOT_SET)) { + sb.append(startTag("!" + UNDERLINED)); + } + + // ## hover + // ### only start if prevComp didn't start the same one + final HoverEvent hov = style.hoverEvent(); + if (hov != null && (previous == null || areDifferent(hov, previous.hoverEvent()))) { + serializeHoverEvent(sb, hov); + } + + // ## click + // ### only start if previous didn't start the same one + final ClickEvent click = style.clickEvent(); + if (click != null && (previous == null || areDifferent(click, previous.clickEvent()))) { + sb.append(startTag(String.format("%s" + SEPARATOR + "%s" + SEPARATOR + "\"%s\"", CLICK, ClickEvent.Action.NAMES.key(click.action()), click.value()))); + } + + // ## insertion + // ### only start if previous didn't start the same one + final String insert = style.insertion(); + if (insert != null && (previous == null || !insert.equals(previous.insertion()))) { + sb.append(startTag(INSERTION + SEPARATOR + insert)); + } + + // ## font + final Key font = style.font(); + if (font != null && (previous == null || !font.equals(previous.font()))) { + sb.append(startTag(FONT + SEPARATOR + font.asString())); + } + + // # append text + if (node.component() instanceof TextComponent) { + sb.append(((TextComponent) node.component()).content()); + } else { + handleDifferentComponent(node.component(), sb); + } + + // # end tags + // ### these must be in reverse order to avoid https://github.com/KyoriPowered/adventure-text-minimessage/issues/151 + + // ## font + // ### only end insertion if next tag is different + if (next != null && style.font() != null) { + if (!Objects.equals(style.font(), next.font())) { + sb.append(endTag(FONT)); + } + } + + // ## insertion + // ### only end insertion if next tag is different + if (next != null && style.insertion() != null) { + if (!Objects.equals(style.insertion(), next.insertion())) { + sb.append(endTag(INSERTION)); + } + } + + // ## click + // ### only end click if next tag is different + if (next != null && style.clickEvent() != null) { + if (areDifferent(Objects.requireNonNull(style.clickEvent()), next.clickEvent())) { + sb.append(endTag(CLICK)); + } + } + + // ## hover + // ### only end hover if next tag is different + if (next != null && style.hoverEvent() != null) { + if (areDifferent(Objects.requireNonNull(style.hoverEvent()), next.hoverEvent())) { + sb.append(endTag(HOVER)); + } + } + + // ## decoration + // ### only end decoration if next tag is different + if (next != null) { + if (style.hasDecoration(TextDecoration.UNDERLINED) && !next.hasDecoration(TextDecoration.UNDERLINED)) { + sb.append(endTag(UNDERLINED)); + } + if (style.hasDecoration(TextDecoration.STRIKETHROUGH) && !next.hasDecoration(TextDecoration.STRIKETHROUGH)) { + sb.append(endTag(STRIKETHROUGH)); + } + if (style.hasDecoration(TextDecoration.OBFUSCATED) && !next.hasDecoration(TextDecoration.OBFUSCATED)) { + sb.append(endTag(OBFUSCATED)); + } + if (style.hasDecoration(TextDecoration.ITALIC) && !next.hasDecoration(TextDecoration.ITALIC)) { + sb.append(endTag(ITALIC)); + } + if (style.hasDecoration(TextDecoration.BOLD) && !next.hasDecoration(TextDecoration.BOLD)) { + sb.append(endTag(BOLD)); + } + } + + // ## disabled decorations + // ### only end decoration if next tag is different + if (next != null) { + if (style.decoration(TextDecoration.UNDERLINED) == TextDecoration.State.FALSE && next.decoration(TextDecoration.UNDERLINED) == TextDecoration.State.NOT_SET) { + sb.append(endTag("!" + UNDERLINED)); + } + if (style.decoration(TextDecoration.STRIKETHROUGH) == TextDecoration.State.FALSE && next.decoration(TextDecoration.STRIKETHROUGH) == TextDecoration.State.NOT_SET) { + sb.append(endTag("!" + STRIKETHROUGH)); + } + if (style.decoration(TextDecoration.OBFUSCATED) == TextDecoration.State.FALSE && next.decoration(TextDecoration.OBFUSCATED) == TextDecoration.State.NOT_SET) { + sb.append(endTag("!" + OBFUSCATED)); + } + if (style.decoration(TextDecoration.ITALIC) == TextDecoration.State.FALSE && next.decoration(TextDecoration.ITALIC) == TextDecoration.State.NOT_SET) { + sb.append(endTag("!" + ITALIC)); + } + if (style.decoration(TextDecoration.BOLD) == TextDecoration.State.FALSE && next.decoration(TextDecoration.BOLD) == TextDecoration.State.NOT_SET) { + sb.append(endTag("!" + BOLD)); + } + } + + // ## color + if (next != null && style.color() != null && next.color() != style.color()) { + sb.append(endColor(Objects.requireNonNull(style.color()))); + } + + return sb.toString(); + } + + private static void serializeHoverEvent(final @NotNull StringBuilder sb, final @NotNull HoverEvent hov) { + if (hov.action() == HoverEvent.Action.SHOW_TEXT) { + sb.append(startTag(HOVER + SEPARATOR + HoverEvent.Action.NAMES.key(hov.action()) + SEPARATOR + "\"" + serialize((Component) hov.value()).replace("\"", "\\\"") + "\"")); + } else if (hov.action() == HoverEvent.Action.SHOW_ITEM) { + final HoverEvent.ShowItem showItem = (HoverEvent.ShowItem) hov.value(); + final String nbt; + if (showItem.nbt() != null) { + nbt = SEPARATOR + "\"" + showItem.nbt().string().replace("\"", "\\\"") + "\""; + } else { + nbt = ""; + } + sb.append(startTag(HOVER + SEPARATOR + HoverEvent.Action.NAMES.key(hov.action()) + SEPARATOR + "'" + showItem.item().asString() + "'" + SEPARATOR + showItem.count() + nbt)); + } else if (hov.action() == HoverEvent.Action.SHOW_ENTITY) { + final HoverEvent.ShowEntity showEntity = (HoverEvent.ShowEntity) hov.value(); + final String displayName; + if (showEntity.name() != null) { + displayName = SEPARATOR + "\"" + serialize(showEntity.name()).replace("\"", "\\\"") + "\""; + } else { + displayName = ""; + } + sb.append(startTag(HOVER + SEPARATOR + HoverEvent.Action.NAMES.key(hov.action()) + SEPARATOR + "'" + showEntity.type().asString() + "'" + SEPARATOR + showEntity.id() + displayName)); + } else { + throw new RuntimeException("Don't know how to serialize '" + hov + "'!"); + } + } + + private static boolean areDifferent(final @NotNull ClickEvent c1, final @Nullable ClickEvent c2) { + if (c2 == null) return true; + return !c1.equals(c2) && (!c1.action().equals(c2.action()) || !c1.value().equals(c2.value())); + } + + private static boolean areDifferent(final @NotNull HoverEvent h1, final @Nullable HoverEvent h2) { + if (h2 == null) return true; + return !h1.equals(h2) && (!h1.action().equals(h2.action())); // TODO also compare value + } + + private static @NotNull String startColor(final @NotNull TextColor color) { + if (color instanceof NamedTextColor) { + return startTag(Objects.requireNonNull(NamedTextColor.NAMES.key((NamedTextColor) color))); + } else { + return startTag(COLOR + SEPARATOR + color.asHexString()); + } + } + + private static @NotNull String endColor(final @NotNull TextColor color) { + if (color instanceof NamedTextColor) { + return endTag(Objects.requireNonNull(NamedTextColor.NAMES.key((NamedTextColor) color))); + } else { + return endTag(COLOR + SEPARATOR + color.asHexString()); + } + } + + private static @NotNull String startTag(final @NotNull String content) { + return "" + TAG_START + content + TAG_END; + } + + private static @NotNull String endTag(final @NotNull String content) { + return ("" + TAG_START) + CLOSE_TAG + content + TAG_END; + } + + private static void handleDifferentComponent(final @NotNull Component component, final @NotNull StringBuilder sb) { + if (component instanceof KeybindComponent) { + sb.append(startTag(KEYBIND + SEPARATOR + ((KeybindComponent) component).keybind())); + } else if (component instanceof TranslatableComponent) { + final StringBuilder args = new StringBuilder(); + for (final Component arg : ((TranslatableComponent) component).args()) { + args.append(SEPARATOR) + .append("\"") + .append(serialize(arg).replace("\"", "\\\"")) + .append("\""); + } + sb.append(startTag(TRANSLATABLE + SEPARATOR + ((TranslatableComponent) component).key() + args)); + } + } + + private static class ComponentNode { + private final Component component; + private final Style style; + + ComponentNode(@NotNull final Component component) { + this(component, null); + } + + ComponentNode(@NotNull final Component component, @Nullable final Style parent) { + this.component = component; + this.style = (parent == null) ? component.style() : component.style().merge(parent, Style.Merge.Strategy.IF_ABSENT_ON_TARGET); + } + + public Component component() { + return this.component; + } + + public Style style() { + return this.style; + } + } +} diff --git a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/Tokens.java b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/Tokens.java new file mode 100644 index 000000000..7550776d5 --- /dev/null +++ b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/Tokens.java @@ -0,0 +1,78 @@ +/* + * This file is part of adventure, licensed under the MIT License. + * + * Copyright (c) 2017-2021 KyoriPowered + * + * 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 net.kyori.adventure.text.minimessage; + +/** + * Tokens used in the MiniMessage format. + * + * @since 4.10.0 + */ +public final class Tokens { + // vanilla components + public static final String CLICK = "click"; + public static final String HOVER = "hover"; + public static final String KEYBIND = "key"; + public static final String TRANSLATABLE = "lang"; + public static final String TRANSLATABLE_2 = "translate"; + public static final String TRANSLATABLE_3 = "tr"; + public static final String INSERTION = "insert"; + public static final String COLOR = "color"; + public static final String COLOR_2 = "colour"; + public static final String COLOR_3 = "c"; + public static final String HEX = "#"; + public static final String FONT = "font"; + + // vanilla decoration + public static final String UNDERLINED = "underlined"; + public static final String UNDERLINED_2 = "u"; + public static final String STRIKETHROUGH = "strikethrough"; + public static final String STRIKETHROUGH_2 = "st"; + public static final String OBFUSCATED = "obfuscated"; + public static final String OBFUSCATED_2 = "obf"; + public static final String ITALIC = "italic"; + public static final String ITALIC_2 = "em"; + public static final String ITALIC_3 = "i"; + public static final String BOLD = "bold"; + public static final String BOLD_2 = "b"; + public static final String RESET = "reset"; + public static final String RESET_2 = "r"; + public static final String PRE = "pre"; + + // minimessage components + public static final String RAINBOW = "rainbow"; + public static final String REVERSE = "!"; + public static final String GRADIENT = "gradient"; + + // minimessage tags + public static final char TAG_START = '<'; + public static final char TAG_END = '>'; + public static final char CLOSE_TAG = '/'; + public static final char SEPARATOR = ':'; + + // misc + public static final char ESCAPE = '\\'; + + private Tokens() { + } +} diff --git a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/package-info.java b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/package-info.java new file mode 100644 index 000000000..d9335962f --- /dev/null +++ b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/package-info.java @@ -0,0 +1,27 @@ +/* + * This file is part of adventure, licensed under the MIT License. + * + * Copyright (c) 2017-2021 KyoriPowered + * + * 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. + */ +/** + * MiniMessage. + */ +package net.kyori.adventure.text.minimessage; diff --git a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/parser/ParsingException.java b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/parser/ParsingException.java new file mode 100644 index 000000000..d66b501f0 --- /dev/null +++ b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/parser/ParsingException.java @@ -0,0 +1,242 @@ +/* + * This file is part of adventure, licensed under the MIT License. + * + * Copyright (c) 2017-2021 KyoriPowered + * + * 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 net.kyori.adventure.text.minimessage.parser; + +import java.util.Arrays; +import java.util.List; +import net.kyori.adventure.text.minimessage.parser.node.TagPart; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * An exception that happens while parsing. + * + * @since 4.10.0 + */ +public class ParsingException extends RuntimeException { + private static final long serialVersionUID = 2507190809441787201L; + + private @Nullable String originalText; + private Token @NotNull [] tokens; + + /** + * Create a new parsing exception. + * + * @param message the detail message + * @param originalText the original text which was parsed + * @param tags the tag parts which caused the error + * @since 4.10.0 + */ + public ParsingException( + final String message, + final @Nullable String originalText, + final @NotNull List tags + ) { + this(message, originalText, tagsToTokens(tags)); + } + + /** + * Create a new parsing exception. + * + * @param message the detail message + * @param originalText the origina text which was parsed + * @param tokens the token which caused the error + * @since 4.10.0 + */ + public ParsingException( + final String message, + final @Nullable String originalText, + final @NotNull Token @NotNull ... tokens + ) { + super(message); + this.tokens = tokens; + this.originalText = originalText; + } + + /** + * Create a new parsing exception. + * + * @param message the detail message + * @param originalText the original text which was parsed + * @param cause the cause + * @param tags tag parts that caused the errors + * @since 4.10.0 + */ + public ParsingException( + final String message, + final @Nullable String originalText, + final @Nullable Throwable cause, + final @NotNull List tags + ) { + this(message, originalText, cause, tagsToTokens(tags)); + } + + /** + * Create a new parsing exception. + * + * @param message the detail message + * @param originalText the original text which was parsed + * @param cause the cause + * @param tokens the token which caused the error + * @since 4.10.0 + */ + public ParsingException( + final String message, + final @Nullable String originalText, + final @Nullable Throwable cause, + final @NotNull Token @NotNull ... tokens + ) { + super(message, cause); + this.tokens = tokens; + this.originalText = originalText; + } + + /** + * Create a new parsing exception. + * + * @param message the detail message + * @param tokens the token which caused the error + * @since 4.10.0 + */ + public ParsingException(final String message, final List tokens) { + this(message, tagsToTokens(tokens)); + } + + /** + * Create a new parsing exception. + * + * @param message the detail message + * @param tokens the token which caused the error + * @since 4.10.0 + */ + public ParsingException(final String message, final @NotNull Token @NotNull ... tokens) { + this(message, null, null, tokens); + } + + /** + * Create a new parsing exception. + * + * @param message the detail message + * @param cause the cause + * @param tags the tag parts that caused the error + * @since 4.10.0 + */ + public ParsingException( + final String message, + final @Nullable Throwable cause, + final @NotNull List tags + ) { + this(message, null, cause, tagsToTokens(tags)); + } + + /** + * Create a new parsing exception. + * + * @param message the detail message + * @param cause the cause + * @param tokens the token which caused the error + * @since 4.10.0 + */ + public ParsingException( + final String message, + final @Nullable Throwable cause, + final @NotNull Token @NotNull ... tokens + ) { + this(message, null, cause, tokens); + } + + @Override + public String getMessage() { + final String arrowInfo = this.tokens().length != 0 + ? "\n\t" + this.arrow() + : ""; + final String messageInfo = this.originalText() != null + ? "\n\t" + this.originalText() + arrowInfo + : ""; + return super.getMessage() + messageInfo; + } + + /** + * Get the message which caused this exception. + * + * @return the original message + * @since 4.10.0 + */ + public @Nullable String originalText() { + return this.originalText; + } + + /** + * Set the message which caused this exception. + * + * @param originalText the original message + * @since 4.10.0 + */ + public void originalText(final @NotNull String originalText) { + this.originalText = originalText; + } + + /** + * Gets the tokens associated with this parsing error. + * + * @return the tokens for this error + * @since 4.10.0 + */ + public @NotNull Token @NotNull [] tokens() { + return this.tokens; + } + + /** + * Sets the tokens associated with this parsing error. + * + * @param tokens the tokens for this error + * @since 4.10.0 + */ + public void tokens(final @NotNull Token @NotNull [] tokens) { + this.tokens = tokens; + } + + private String arrow() { + final @NotNull Token[] ts = this.tokens(); + final char[] chars = new char[ts[ts.length - 1].endIndex()]; + + int i = 0; + for (final Token t : ts) { + Arrays.fill(chars, i, t.startIndex(), ' '); + chars[t.startIndex()] = '^'; + Arrays.fill(chars, t.startIndex() + 1, t.endIndex() - 1, '~'); + chars[t.endIndex() - 1] = '^'; + i = t.endIndex(); + } + return new String(chars); + } + + private static Token[] tagsToTokens(final List tags) { + final Token[] tokens = new Token[tags.size()]; + for (int i = 0, length = tokens.length; i < length; i++) { + tokens[i] = tags.get(i).token(); + } + return tokens; + } +} diff --git a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/parser/Token.java b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/parser/Token.java new file mode 100644 index 000000000..1c46c64e7 --- /dev/null +++ b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/parser/Token.java @@ -0,0 +1,123 @@ +/* + * This file is part of adventure, licensed under the MIT License. + * + * Copyright (c) 2017-2021 KyoriPowered + * + * 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 net.kyori.adventure.text.minimessage.parser; + +import java.util.List; + +/** + * Represents a token for the lexer. + * + * @since 4.10.0 + */ +public final class Token { + private final int startIndex; + private final int endIndex; + private final TokenType type; + + private List childTokens = null; + + /** + * Creates a new token. + * + * @param startIndex the start index of the token + * @param endIndex the end index of the token + * @param type the type of the token + * @since 4.10.0 + */ + public Token(final int startIndex, final int endIndex, final TokenType type) { + this.startIndex = startIndex; + this.endIndex = endIndex; + this.type = type; + } + + /** + * Returns the start index of this token. + * + * @return the start index + * @since 4.10.0 + */ + public int startIndex() { + return this.startIndex; + } + + /** + * Returns the end index of this token. + * + * @return the end index + * @since 4.10.0 + */ + public int endIndex() { + return this.endIndex; + } + + /** + * Returns the type of this token. + * + * @return the type + * @since 4.10.0 + */ + public TokenType type() { + return this.type; + } + + /** + * Returns the children of this token. + * + * @return the child tokens + * @since 4.10.0 + */ + public List childTokens() { + return this.childTokens; + } + + /** + * Sets the children of this token. + * + * @param childTokens the new children + * @since 4.10.0 + */ + public void childTokens(final List childTokens) { + this.childTokens = childTokens; + } + + /** + * Get the value of this token from the complete message. + * + * @param message the message to read + * @return the value of this token + * @since 4.10.0 + */ + public CharSequence get(final CharSequence message) { + return message.subSequence(this.startIndex, this.endIndex); + } + + @Override + public String toString() { + return "Token{" + + "startIndex=" + this.startIndex + + ", endIndex=" + this.endIndex + + ", type=" + this.type + + '}'; + } +} diff --git a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/parser/TokenParser.java b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/parser/TokenParser.java new file mode 100644 index 000000000..2db8cbe78 --- /dev/null +++ b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/parser/TokenParser.java @@ -0,0 +1,654 @@ +/* + * This file is part of adventure, licensed under the MIT License. + * + * Copyright (c) 2017-2021 KyoriPowered + * + * 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 net.kyori.adventure.text.minimessage.parser; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.ListIterator; +import java.util.Locale; +import java.util.function.BiPredicate; +import java.util.function.Function; +import java.util.function.IntPredicate; +import java.util.function.Predicate; +import net.kyori.adventure.text.minimessage.Tokens; +import net.kyori.adventure.text.minimessage.parser.match.MatchedTokenConsumer; +import net.kyori.adventure.text.minimessage.parser.match.StringResolvingMatchedTokenConsumer; +import net.kyori.adventure.text.minimessage.parser.match.TokenListProducingMatchedTokenConsumer; +import net.kyori.adventure.text.minimessage.parser.node.ElementNode; +import net.kyori.adventure.text.minimessage.parser.node.PlaceholderNode; +import net.kyori.adventure.text.minimessage.parser.node.RootNode; +import net.kyori.adventure.text.minimessage.parser.node.TagNode; +import net.kyori.adventure.text.minimessage.parser.node.TagPart; +import net.kyori.adventure.text.minimessage.parser.node.TextNode; +import net.kyori.adventure.text.minimessage.placeholder.Placeholder; +import net.kyori.adventure.text.minimessage.placeholder.Placeholder.StringPlaceholder; +import net.kyori.adventure.text.minimessage.placeholder.PlaceholderResolver; +import net.kyori.adventure.text.minimessage.transformation.Inserting; +import net.kyori.adventure.text.minimessage.transformation.Transformation; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Handles parsing a string into a list of tokens and then into a tree of nodes. + * + * @since 4.10.0 + */ +public final class TokenParser { + private static final int MAX_DEPTH = 16; + + private TokenParser() { + } + + /** + * Parse a minimessage string into a tree of nodes. + * + * @param message the minimessage string to parse + * @return the root of the resulting tree + * @since 4.10.0 + */ + public static ElementNode parse( + final @NotNull Function transformationFactory, + final @NotNull BiPredicate tagNameChecker, + final @NotNull PlaceholderResolver placeholderResolver, + final @NotNull String message, + final boolean strict + ) { + // first resolve placeholders... + final String actualMessage = resolvePlaceholders(message, string -> tagNameChecker.test(string, false), placeholderResolver); + + // then collect tokens... + final List tokens = tokenize(actualMessage); + + // then build the tree! + return buildTree(transformationFactory, tagNameChecker, placeholderResolver, tokens, actualMessage, strict); + } + + /** + * Resolves placeholders on a string. + * + * @param message the message + * @param tagNameChecker a predicate to check if a matched token is a in-built transformation tag + * @param placeholderResolver the placeholder resolver + * @return the resulting string + * @since 4.10.0 + */ + public static String resolvePlaceholders(final String message, final Predicate tagNameChecker, final PlaceholderResolver placeholderResolver) { + int passes = 0; + String lastResult; + String result = message; + + do { + lastResult = result; + final StringResolvingMatchedTokenConsumer stringTokenResolver = new StringResolvingMatchedTokenConsumer(lastResult, tagNameChecker, placeholderResolver); + parseString(lastResult, stringTokenResolver); + result = stringTokenResolver.result(); + passes++; + } while (passes < MAX_DEPTH && !lastResult.equals(result)); + + return lastResult; + } + + /** + * Tokenize a minimessage string into a list of tokens. + * + * @param message the minimessage string to parse + * @return the root tokens + * @since 4.10.0 + */ + public static List tokenize(final String message) { + final TokenListProducingMatchedTokenConsumer listProducer = new TokenListProducingMatchedTokenConsumer(message); + parseString(message, listProducer); + final List tokens = listProducer.result(); + parseSecondPass(message, tokens); + return tokens; + } + + enum FirstPassState { + NORMAL, + TAG, + STRING; + } + + /** + * Parses a string, providing information on matched tokens to the matched token consumer. + * + * @param message the message + * @param consumer the consumer + * @since 4.10.0 + */ + public static void parseString(final String message, final MatchedTokenConsumer consumer) { + FirstPassState state = FirstPassState.NORMAL; + // If the current state is escaped then the next character is skipped + boolean escaped = false; + + int currentTokenEnd = 0; + // Marker is the starting index for the current token + int marker = -1; + char currentStringChar = 0; + + final int length = message.length(); + for (int i = 0; i < length; i++) { + final int codePoint = message.codePointAt(i); + if (!Character.isBmpCodePoint(codePoint)) { + i++; + } + + if (!escaped) { + // if we're trying to escape and the next character exists + if (codePoint == Tokens.ESCAPE && i + 1 < message.length()) { + final int nextCodePoint = message.codePointAt(i + 1); + + switch (state) { + case NORMAL: + // allow escaping open tokens + escaped = nextCodePoint == Tokens.TAG_START; + break; + case STRING: + // allow escaping closing string chars + escaped = currentStringChar == nextCodePoint; + break; + case TAG: + break; + } + + // only escape if we need to + if (escaped) { + continue; + } + } + } else { + escaped = false; + continue; + } + + switch (state) { + case NORMAL: + if (codePoint == Tokens.TAG_START) { + // Possibly a tag + marker = i; + state = FirstPassState.TAG; + } + break; + case TAG: + switch (codePoint) { + case Tokens.TAG_END: + if (i == marker + 1) { + // This is empty, <>, so it's not a tag + state = FirstPassState.NORMAL; + break; + } + + // We found a tag + if (currentTokenEnd != marker) { + // anything not matched up to this point is normal text + consumer.accept(currentTokenEnd, marker, TokenType.TEXT); + } + currentTokenEnd = i + 1; + + // closing tags start with tokens) { + for (final Token token : tokens) { + final TokenType type = token.type(); + if (type != TokenType.OPEN_TAG && type != TokenType.CLOSE_TAG) { + continue; + } + + // Only look inside the tag <[/] and > + final int startIndex = type == TokenType.OPEN_TAG ? token.startIndex() + 1 : token.startIndex() + 2; + final int endIndex = token.endIndex() - 1; + + SecondPassState state = SecondPassState.NORMAL; + boolean escaped = false; + char currentStringChar = 0; + + // Marker is the starting index for the current token + int marker = startIndex; + + for (int i = startIndex; i < endIndex; i++) { + final int codePoint = message.codePointAt(i); + if (!Character.isBmpCodePoint(i)) { + i++; + } + + if (!escaped) { + // if we're trying to escape and the next character exists + if (codePoint == Tokens.ESCAPE && i + 1 < message.length()) { + final int nextCodePoint = message.codePointAt(i + 1); + + switch (state) { + case NORMAL: + // allow escaping open tokens + escaped = nextCodePoint == Tokens.TAG_START; + break; + case STRING: + // allow escaping closing string chars + escaped = currentStringChar == nextCodePoint; + break; + } + + // only escape if we need to + if (escaped) { + continue; + } + } + } else { + escaped = false; + continue; + } + + switch (state) { + case NORMAL: + // Values are split by : unless it's in a URL + if (codePoint == Tokens.SEPARATOR) { + if (boundsCheck(message, i, 2) && message.charAt(i + 1) == '/' && message.charAt(i + 2) == '/') { + break; + } + if (marker == i) { + // 2 colons side-by-side like <::> or <:text> or would lead to this happening + insert(token, new Token(i, i, TokenType.TAG_VALUE)); + marker++; + } else { + insert(token, new Token(marker, i, TokenType.TAG_VALUE)); + marker = i + 1; + } + } else if (codePoint == '\'' || codePoint == '"') { + state = SecondPassState.STRING; + currentStringChar = (char) codePoint; + } + break; + case STRING: + if (codePoint == currentStringChar) { + state = SecondPassState.NORMAL; + } + break; + } + } + + // anything not matched is the final part + if (token.childTokens() == null || token.childTokens().isEmpty()) { + insert(token, new Token(startIndex, endIndex, TokenType.TAG_VALUE)); + } else { + final int end = token.childTokens().get(token.childTokens().size() - 1).endIndex(); + if (end != endIndex) { + insert(token, new Token(end + 1, endIndex, TokenType.TAG_VALUE)); + } + } + } + } + + /* + * Build a tree from the OPEN_TAG and CLOSE_TAG tokens + */ + private static ElementNode buildTree( + final @NotNull Function transformationFactory, + final @NotNull BiPredicate tagNameChecker, + final @NotNull PlaceholderResolver placeholderResolver, + final @NotNull List tokens, + final @NotNull String message, + final boolean strict + ) { + final RootNode root = new RootNode(message); + ElementNode node = root; + + for (final Token token : tokens) { + final TokenType type = token.type(); + switch (type) { + case TEXT: + node.addChild(new TextNode(node, token, message)); + break; + + case OPEN_TAG: + final TagNode tagNode = new TagNode(node, token, message, placeholderResolver); + if (isReset(tagNode.name())) { + // tags get special treatment and don't appear in the tree + // instead, they close all currently open tags + + if (strict) { + throw new ParsingException(" tags are not allowed when strict mode is enabled", message, token); + } + node = root; + } else { + final Placeholder placeholder = placeholderResolver.resolve(tagNode.name()); + if (placeholder instanceof StringPlaceholder) { + // String placeholders are inserted into the tree as raw text nodes, not parsed + node.addChild(new PlaceholderNode(node, token, message, ((StringPlaceholder) placeholder).value())); + } else if (tagNameChecker.test(tagNode.name(), true)) { + final Transformation transformation = transformationFactory.apply(tagNode); + if (transformation == null) { + // something went wrong, ignore it + // if strict mode is enabled this will throw an exception for us + node.addChild(new TextNode(node, token, message)); + } else { + // This is a recognized tag, goes in the tree + tagNode.transformation(transformation); + node.addChild(tagNode); + if (!(transformation instanceof Inserting)) { + // this tag has children + node = tagNode; + } + } + } else { + // not recognized, plain text + node.addChild(new TextNode(node, token, message)); + } + } + break; // OPEN_TAG + + case CLOSE_TAG: + final List childTokens = token.childTokens(); + if (childTokens.isEmpty()) { + throw new IllegalStateException("CLOSE_TAG token somehow has no children - " + + "the parser should not allow this. Original text: " + message); + } + + final ArrayList closeValues = new ArrayList<>(childTokens.size()); + for (final Token childToken : childTokens) { + closeValues.add(TagPart.unquoteAndEscape(message, childToken.startIndex(), childToken.endIndex())); + } + + final String closeTagName = closeValues.get(0); + if (isReset(closeTagName)) { + // This is a synthetic node, closing it means nothing in the context of building a tree + continue; + } + + if (!tagNameChecker.test(closeTagName, false)) { + // tag does not exist, so treat it as text + node.addChild(new TextNode(node, token, message)); + continue; + } + + ElementNode parentNode = node; + while (parentNode instanceof TagNode) { + final List openParts = ((TagNode) parentNode).parts(); + + if (tagCloses(closeValues, openParts)) { + if (parentNode != node && strict) { + final String msg = "Unclosed tag encountered; " + ((TagNode) node).name() + " is not closed, because " + + closeValues.get(0) + " was closed first."; + throw new ParsingException(msg, message, parentNode.token(), node.token(), token); + } + + final ElementNode par = parentNode.parent(); + if (par != null) { + node = par; + } else { + throw new IllegalStateException("Root node matched with close tag value, " + + "this should not be possible. Original text: " + message); + } + break; + } + + parentNode = parentNode.parent(); + } + if (parentNode == null || parentNode instanceof RootNode) { + // This means the closing tag didn't match to anything + // Since open tags which don't match to anything is never an error, neither is this + node.addChild(new TextNode(node, token, message)); + break; + } + break; // CLOSE_TAG + } + } + + if (strict && root != node) { + final ArrayList openTags = new ArrayList<>(); + { + ElementNode n = node; + while (n != null) { + if (n instanceof TagNode) { + openTags.add((TagNode) n); + } else { + break; + } + n = n.parent(); + } + } + + final Token[] errorTokens = new Token[openTags.size()]; + + final StringBuilder sb = new StringBuilder("All tags must be explicitly closed while in strict mode. " + + "End of string found with open tags: "); + + int i = 0; + final ListIterator iter = openTags.listIterator(openTags.size()); + while (iter.hasPrevious()) { + final TagNode n = iter.previous(); + errorTokens[i++] = n.token(); + + sb.append(n.name()); + if (iter.hasPrevious()) { + sb.append(", "); + } + } + + throw new ParsingException(sb.toString(), message, errorTokens); + } + + return root; + } + + /** + * Parse a minimessage string into another string, resolving only the string placeholders present in the message. + * + * @param message the minimessage string to parse + * @param placeholderResolver the placeholder resolver to use to find string placeholders + * @return the message, with non-string placeholders still intact + * @since 4.10.0 + */ + public static String resolveStringPlaceholders( + final @NotNull String message, + final @NotNull PlaceholderResolver placeholderResolver + ) { + final List tokens = tokenize(message); + final StringBuilder sb = new StringBuilder(); + + for (final Token token : tokens) { + final TokenType type = token.type(); + switch (type) { + case TEXT: + case CLOSE_TAG: + sb.append(token.get(message)); + break; + + case OPEN_TAG: + if (token.childTokens() != null && token.childTokens().size() == 1) { + final CharSequence name = token.childTokens().get(0).get(message); + final Placeholder placeholder = placeholderResolver.resolve(name.toString().toLowerCase(Locale.ROOT)); + if (placeholder instanceof StringPlaceholder) { + sb.append(((StringPlaceholder) placeholder).value()); + break; + } + } + sb.append(token.get(message)); + break; + } + } + + return sb.toString(); + } + + private static boolean isReset(final String input) { + return input.equalsIgnoreCase(Tokens.RESET) || input.equalsIgnoreCase(Tokens.RESET_2); + } + + /** + * Determine if a set of close string parts closes the given list of open tag parts. If the open parts starts with + * the set of close parts, then this method returns {@code true}. + * + * @param closeParts The parts of the close tag + * @param openParts The parts of the open tag + * @return {@code true} if the given close parts closes the open tag parts. + */ + private static boolean tagCloses(final List closeParts, final List openParts) { + if (closeParts.size() > openParts.size()) { + return false; + } + // The tag name is case-insensitive, but the tag values are not + if (!closeParts.get(0).equalsIgnoreCase(openParts.get(0).value())) { + return false; + } + for (int i = 1; i < closeParts.size(); i++) { + if (!closeParts.get(i).equals(openParts.get(i).value())) { + return false; + } + } + return true; + } + + /** + * Returns {@code true} if it's okay to check for characters up to the given length. Returns {@code false} if the + * string is too short. + * + * @param text The string to check. + * @param index The index to start from. + * @param length The length to check. + * @return {@code true} if the string's length is at least as long as {@code index + length}. + */ + private static boolean boundsCheck(final String text, final int index, final int length) { + return index + length < text.length(); + } + + /** + * Optimized insert method for adding child tokens to the given {@code token}. + * + * @param token The token to add {@code value} as a child. + * @param value The token to add to {@code token}. + */ + private static void insert(final Token token, final Token value) { + if (token.childTokens() == null) { + token.childTokens(Collections.singletonList(value)); + return; + } + if (token.childTokens().size() == 1) { + final ArrayList list = new ArrayList<>(3); + list.add(token.childTokens().get(0)); + list.add(value); + token.childTokens(list); + } else { + token.childTokens().add(value); + } + } + + enum SecondPassState { + NORMAL, + STRING; + } + + /** + * Removes escaping {@code '\`} characters from a substring where the subsequent character matches a given predicate. + * + * @param text the input text + * @param startIndex the starting index of the substring + * @param endIndex the ending index of the substring + * @param escapes the predicate to determine if an escape happened + * @return the output escaped substring + * @since 4.10.0 + */ + public static String unescape(final String text, final int startIndex, final int endIndex, final IntPredicate escapes) { + int from = startIndex; + + int i = text.indexOf('\\', from); + if (i == -1 || i >= endIndex) { + return text.substring(from, endIndex); + } + + final StringBuilder sb = new StringBuilder(endIndex - startIndex); + while (i != -1 && i + 1 < endIndex) { + if (escapes.test(text.codePointAt(i + 1))) { + sb.append(text, from, i); + i++; + + if (i >= endIndex) { + from = endIndex; + break; + } + + final int codePoint = text.codePointAt(i); + sb.appendCodePoint(codePoint); + + if (Character.isBmpCodePoint(codePoint)) { + i += 1; + } else { + i += 2; + } + + if (i >= endIndex) { + from = endIndex; + break; + } + } else { + i++; + sb.append(text, from, i); + } + + from = i; + i = text.indexOf('\\', from); + } + + sb.append(text, from, endIndex); + + return sb.toString(); + } +} diff --git a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/parser/TokenType.java b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/parser/TokenType.java new file mode 100644 index 000000000..b0e8322f5 --- /dev/null +++ b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/parser/TokenType.java @@ -0,0 +1,36 @@ +/* + * This file is part of adventure, licensed under the MIT License. + * + * Copyright (c) 2017-2021 KyoriPowered + * + * 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 net.kyori.adventure.text.minimessage.parser; + +/** + * Represents the type of a token. + * + * @since 4.10.0 + */ +public enum TokenType { + TEXT, + OPEN_TAG, + CLOSE_TAG, + TAG_VALUE; +} diff --git a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/parser/match/MatchedTokenConsumer.java b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/parser/match/MatchedTokenConsumer.java new file mode 100644 index 000000000..09f8ad040 --- /dev/null +++ b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/parser/match/MatchedTokenConsumer.java @@ -0,0 +1,82 @@ +/* + * This file is part of adventure, licensed under the MIT License. + * + * Copyright (c) 2017-2021 KyoriPowered + * + * 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 net.kyori.adventure.text.minimessage.parser.match; + +import net.kyori.adventure.text.minimessage.parser.TokenType; +import org.jetbrains.annotations.MustBeInvokedByOverriders; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.UnknownNullability; + +/** + * A consumer of a region of a string that was identified as a token. + * + * @param the return result + * @since 4.10.0 + */ +public abstract class MatchedTokenConsumer { + protected final String input; + + private int lastIndex = -1; + + /** + * Creates a new matched token consumer. + * + * @param input the input + * @since 4.10.0 + */ + public MatchedTokenConsumer(final @NotNull String input) { + this.input = input; + } + + /** + * Accepts a matched token. + * + * @param start the start of the token + * @param end the end of the token + * @param tokenType the type of the token + * @since 4.10.0 + */ + @MustBeInvokedByOverriders + public void accept(final int start, final int end, final @NotNull TokenType tokenType) { + this.lastIndex = end; + } + + /** + * Gets the result of this consumer, if any. + * + * @return the result + * @since 4.10.0 + */ + public abstract @UnknownNullability T result(); + + /** + * The last accepted end index, or {@code -1} if no match has been accepted. + * + * @return the last accepted end index + * @since 4.10.0 + */ + public final int lastEndIndex() { + return this.lastIndex; + } +} diff --git a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/parser/match/StringResolvingMatchedTokenConsumer.java b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/parser/match/StringResolvingMatchedTokenConsumer.java new file mode 100644 index 000000000..1fca9ecb4 --- /dev/null +++ b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/parser/match/StringResolvingMatchedTokenConsumer.java @@ -0,0 +1,97 @@ +/* + * This file is part of adventure, licensed under the MIT License. + * + * Copyright (c) 2017-2021 KyoriPowered + * + * 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 net.kyori.adventure.text.minimessage.parser.match; + +import java.util.function.Predicate; +import net.kyori.adventure.text.minimessage.parser.TokenType; +import net.kyori.adventure.text.minimessage.placeholder.Placeholder; +import net.kyori.adventure.text.minimessage.placeholder.Placeholder.StringPlaceholder; +import net.kyori.adventure.text.minimessage.placeholder.PlaceholderResolver; +import org.jetbrains.annotations.NotNull; + +/** + * A matched token consumer that produces a string and returns a copy of the string with placeholders resolved. + * + * @since 4.10.0 + */ +public final class StringResolvingMatchedTokenConsumer extends MatchedTokenConsumer { + private final StringBuilder builder; + private final Predicate tagChecker; + private final PlaceholderResolver placeholderResolver; + + /** + * Creates a placeholder resolving matched token consumer. + * + * @param input the input + * @since 4.10.0 + */ + public StringResolvingMatchedTokenConsumer( + final @NotNull String input, + final @NotNull Predicate tagChecker, + final @NotNull PlaceholderResolver placeholderResolver + ) { + super(input); + this.builder = new StringBuilder(input.length()); + this.tagChecker = tagChecker; + this.placeholderResolver = placeholderResolver; + } + + @Override + public void accept(final int start, final int end, final @NotNull TokenType tokenType) { + super.accept(start, end, tokenType); + + if (tokenType != TokenType.OPEN_TAG) { + // just add it normally, we don't care about other tags + this.builder.append(this.input.substring(start, end)); + } else { + // well, now we need to work out if it's a tag or a placeholder! + final String match = this.input.substring(start, end); + final String tag = this.input.substring(start + 1, end - 1); + + if (this.tagChecker.test(tag)) { + // it's a tag, not a placeholder, so we don't care + this.builder.append(match); + } else { + // we might care if it's a placeholder! + if (this.placeholderResolver.canResolve(tag)) { + final Placeholder placeholder = this.placeholderResolver.resolve(tag); + + if (placeholder instanceof StringPlaceholder) { + // we only care about string placeholders! + this.builder.append(((StringPlaceholder) placeholder).value()); + return; + } + } + + // if we get here, the placeholder wasn't found or was null + this.builder.append(match); + } + } + } + + @Override + public @NotNull String result() { + return this.builder.toString(); + } +} diff --git a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/parser/match/TokenListProducingMatchedTokenConsumer.java b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/parser/match/TokenListProducingMatchedTokenConsumer.java new file mode 100644 index 000000000..6eee188cd --- /dev/null +++ b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/parser/match/TokenListProducingMatchedTokenConsumer.java @@ -0,0 +1,66 @@ +/* + * This file is part of adventure, licensed under the MIT License. + * + * Copyright (c) 2017-2021 KyoriPowered + * + * 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 net.kyori.adventure.text.minimessage.parser.match; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import net.kyori.adventure.text.minimessage.parser.Token; +import net.kyori.adventure.text.minimessage.parser.TokenType; +import org.jetbrains.annotations.NotNull; + +/** + * A matched token consumer that produces a list of matched tokens. + * + * @since 4.10.0 + */ +public final class TokenListProducingMatchedTokenConsumer extends MatchedTokenConsumer> { + private List result = null; + + /** + * Creates a new token list producing matched token consumer. + * + * @param input the input + * @since 4.10.0 + */ + public TokenListProducingMatchedTokenConsumer(final @NotNull String input) { + super(input); + } + + @Override + public void accept(final int start, final int end, final @NotNull TokenType tokenType) { + super.accept(start, end, tokenType); + + if (this.result == null) { + this.result = new ArrayList<>(); + } + + this.result.add(new Token(start, end, tokenType)); + } + + @Override + public @NotNull List result() { + return this.result == null ? Collections.emptyList() : this.result; + } +} diff --git a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/parser/match/package-info.java b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/parser/match/package-info.java new file mode 100644 index 000000000..3ed439a07 --- /dev/null +++ b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/parser/match/package-info.java @@ -0,0 +1,27 @@ +/* + * This file is part of adventure, licensed under the MIT License. + * + * Copyright (c) 2017-2021 KyoriPowered + * + * 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. + */ +/** + * Token consumers. + */ +package net.kyori.adventure.text.minimessage.parser.match; diff --git a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/parser/node/ElementNode.java b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/parser/node/ElementNode.java new file mode 100644 index 000000000..fc019b1fd --- /dev/null +++ b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/parser/node/ElementNode.java @@ -0,0 +1,152 @@ +/* + * This file is part of adventure, licensed under the MIT License. + * + * Copyright (c) 2017-2021 KyoriPowered + * + * 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 net.kyori.adventure.text.minimessage.parser.node; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import net.kyori.adventure.text.minimessage.parser.Token; +import net.kyori.adventure.text.minimessage.parser.TokenType; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Represents a node in the tree. + * + * @since 4.10.0 + */ +public class ElementNode { + private final @Nullable ElementNode parent; + private final @Nullable Token token; + private final String sourceMessage; + private final List children = new ArrayList<>(); + + /** + * Creates a new element node. + * + * @param parent the parent of this node + * @param token the token that created this node + * @param sourceMessage the source message + * @since 4.10.0 + */ + ElementNode(final @Nullable ElementNode parent, final @Nullable Token token, final @NotNull String sourceMessage) { + this.parent = parent; + this.token = token; + this.sourceMessage = sourceMessage; + } + + /** + * Returns the parent of this node, if present. + * + * @return the parent or null + * @since 4.10.0 + */ + public @Nullable ElementNode parent() { + return this.parent; + } + + /** + * Returns the token that lead to the creation of this token. + * + * @return the token + * @since 4.10.0 + */ + public @Nullable Token token() { + return this.token; + } + + /** + * Returns the source message of this node. + * + * @return the source message + * @since 4.10.0 + */ + public @NotNull String sourceMessage() { + return this.sourceMessage; + } + + /** + * Returns the children of this node. + * + * @return the children of this node + * @since 4.10.0 + */ + public List children() { + return this.children; + } + + /** + * Adds a child to this node. + * + *

This method will attempt to join text tokens together if possible.

+ * + * @param childNode the child node to add. + * @since 4.10.0 + */ + public void addChild(final ElementNode childNode) { + final int last = this.children.size() - 1; + if (!(childNode instanceof TextNode) || this.children.isEmpty() || !(this.children.get(last) instanceof TextNode)) { + this.children.add(childNode); + } else { + final TextNode lastNode = (TextNode) this.children.remove(last); + if (lastNode.token().endIndex() == childNode.token().startIndex()) { + final Token replace = new Token(lastNode.token().startIndex(), childNode.token().endIndex(), TokenType.TEXT); + this.children.add(new TextNode(this, replace, lastNode.sourceMessage())); + } else { + // These nodes aren't adjacent in the string, so put the last one back + this.children.add(lastNode); + this.children.add(childNode); + } + } + } + + /** + * Serializes this node to a string. + * + * @param sb the string builder to serialize into + * @param indent the current indent level + * @return the passed string builder, for chaining + * @since 4.10.0 + */ + public @NotNull StringBuilder buildToString(final @NotNull StringBuilder sb, final int indent) { + final char[] in = this.ident(indent); + sb.append(in).append("Node {\n"); + for (final ElementNode child : this.children) { + child.buildToString(sb, indent + 1); + } + sb.append(in).append("}\n"); + return sb; + } + + char @NotNull [] ident(final int indent) { + final char[] c = new char[indent * 2]; + Arrays.fill(c, ' '); + return c; + } + + @Override + public String toString() { + return this.buildToString(new StringBuilder(), 0).toString(); + } +} diff --git a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/parser/node/PlaceholderNode.java b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/parser/node/PlaceholderNode.java new file mode 100644 index 000000000..c87444f7e --- /dev/null +++ b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/parser/node/PlaceholderNode.java @@ -0,0 +1,57 @@ +/* + * This file is part of adventure, licensed under the MIT License. + * + * Copyright (c) 2017-2021 KyoriPowered + * + * 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 net.kyori.adventure.text.minimessage.parser.node; + +import net.kyori.adventure.text.minimessage.parser.Token; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Represents a placeholder replacement in a string. + * + * @since 4.10.0 + */ +public class PlaceholderNode extends ValueNode { + /** + * Creates a new element node. + * + * @param parent the parent of this node + * @param token the token that created this node + * @param sourceMessage the source message + * @since 4.10.0 + */ + public PlaceholderNode( + final @Nullable ElementNode parent, + final @NotNull Token token, + final @NotNull String sourceMessage, + final @NotNull String actualValue + ) { + super(parent, token, sourceMessage, actualValue); + } + + @Override + String valueName() { + return "PlaceholderNode"; + } +} diff --git a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/parser/node/RootNode.java b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/parser/node/RootNode.java new file mode 100644 index 000000000..5243564ea --- /dev/null +++ b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/parser/node/RootNode.java @@ -0,0 +1,41 @@ +/* + * This file is part of adventure, licensed under the MIT License. + * + * Copyright (c) 2017-2021 KyoriPowered + * + * 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 net.kyori.adventure.text.minimessage.parser.node; + +/** + * Represents the root node of a tree. + * + * @since 4.10.0 + */ +public final class RootNode extends ElementNode { + /** + * Creates a new root node. + * + * @param sourceMessage the source message + * @since 4.10.0 + */ + public RootNode(final String sourceMessage) { + super(null, null, sourceMessage); + } +} diff --git a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/parser/node/TagNode.java b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/parser/node/TagNode.java new file mode 100644 index 000000000..9ba84a8c4 --- /dev/null +++ b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/parser/node/TagNode.java @@ -0,0 +1,150 @@ +/* + * This file is part of adventure, licensed under the MIT License. + * + * Copyright (c) 2017-2021 KyoriPowered + * + * 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 net.kyori.adventure.text.minimessage.parser.node; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import net.kyori.adventure.text.minimessage.parser.ParsingException; +import net.kyori.adventure.text.minimessage.parser.Token; +import net.kyori.adventure.text.minimessage.placeholder.PlaceholderResolver; +import net.kyori.adventure.text.minimessage.transformation.Transformation; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Node that represents a tag. + * + * @since 4.10.0 + */ +public final class TagNode extends ElementNode { + private final List parts; + private @Nullable Transformation transformation = null; + + /** + * Creates a new element node. + * + * @param parent the parent of this node + * @param token the token that created this node + * @param sourceMessage the source message + * @param placeholderResolver the placeholder resolver + * @since 4.10.0 + */ + public TagNode( + final @NotNull ElementNode parent, + final @NotNull Token token, + final @NotNull String sourceMessage, + final @NotNull PlaceholderResolver placeholderResolver + ) { + super(parent, token, sourceMessage); + this.parts = genParts(token, sourceMessage, placeholderResolver); + } + + private static @NotNull List genParts( + final @NotNull Token token, + final @NotNull String sourceMessage, + final @NotNull PlaceholderResolver placeholderResolver + ) { + final ArrayList parts = new ArrayList<>(); + + if (token.childTokens() != null) { + for (final Token childToken : token.childTokens()) { + parts.add(new TagPart(sourceMessage, childToken, placeholderResolver)); + } + } + + return parts; + } + + /** + * Returns the parts of this tag. + * + * @return the parts + * @since 4.10.0 + */ + public @NotNull List parts() { + return this.parts; + } + + /** + * Returns the name of this tag. + * + * @return the name + * @since 4.10.0 + */ + public @NotNull String name() { + if (this.parts.isEmpty()) { + throw new ParsingException("Tag has no parts? " + this, this.sourceMessage(), this.token()); + } + return this.parts.get(0).value(); + } + + @Override + public @NotNull Token token() { + return Objects.requireNonNull(super.token(), "token is not set"); + } + + /** + * Gets the transformation attached to this tag node. + * + * @return the transformation for this tag node + * @since 4.10.0 + */ + public @NotNull Transformation transformation() { + return Objects.requireNonNull(this.transformation, "no transformation set"); + } + + /** + * Sets the transformation that is represented by this tag. + * + * @param transformation the transformation + * @since 4.10.0 + */ + public void transformation(final @NotNull Transformation transformation) { + this.transformation = transformation; + } + + @Override + public @NotNull StringBuilder buildToString(final @NotNull StringBuilder sb, final int indent) { + final char[] in = this.ident(indent); + sb.append(in).append("TagNode("); + + final int size = this.parts.size(); + for (int i = 0; i < size; i++) { + final TagPart part = this.parts.get(i); + sb.append('\'').append(part.value()).append('\''); + if (i != size - 1) { + sb.append(", "); + } + } + + sb.append(") {\n"); + + for (final ElementNode child : this.children()) { + child.buildToString(sb, indent + 1); + } + sb.append(in).append("}\n"); + return sb; + } +} diff --git a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/parser/node/TagPart.java b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/parser/node/TagPart.java new file mode 100644 index 000000000..28f64fc80 --- /dev/null +++ b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/parser/node/TagPart.java @@ -0,0 +1,133 @@ +/* + * This file is part of adventure, licensed under the MIT License. + * + * Copyright (c) 2017-2021 KyoriPowered + * + * 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 net.kyori.adventure.text.minimessage.parser.node; + +import net.kyori.adventure.text.minimessage.parser.Token; +import net.kyori.adventure.text.minimessage.parser.TokenParser; +import net.kyori.adventure.text.minimessage.placeholder.PlaceholderResolver; +import org.jetbrains.annotations.NotNull; + +/** + * Represents an inner part of a tag. + * + * @since 4.10.0 + */ +public final class TagPart { + private final String value; + private final Token token; + + /** + * Constructs a new tag part. + * + * @param sourceMessage the source message + * @param token the token that creates this tag part + * @param placeholderResolver the placeholder resolver + * @since 4.10.0 + */ + public TagPart( + final @NotNull String sourceMessage, + final @NotNull Token token, + final @NotNull PlaceholderResolver placeholderResolver + ) { + String v = unquoteAndEscape(sourceMessage, token.startIndex(), token.endIndex()); + v = TokenParser.resolveStringPlaceholders(v, placeholderResolver); + + this.value = v; + this.token = token; + } + + /** + * Returns the value of this tag part. + * + * @return the value + * @since 4.10.0 + */ + public @NotNull String value() { + return this.value; + } + + /** + * Returns the token that created this tag part. + * + * @return the token + * @since 4.10.0 + */ + public @NotNull Token token() { + return this.token; + } + + /** + * Removes leading/trailing quotes from the given string, if necessary, and removes escaping {@code '\'} characters. + * + * @param text the input text + * @param start the starting index of the substring + * @param end the ending index of the substring + * @return the output substring + * @since 4.10.0 + */ + public static @NotNull String unquoteAndEscape(final @NotNull String text, final int start, final int end) { + if (start == end) { + return ""; + } + + int startIndex = start; + int endIndex = end; + + final char firstChar = text.charAt(startIndex); + final char lastChar = text.charAt(endIndex - 1); + if (firstChar == '\'' || firstChar == '"') { + startIndex++; + } + if (lastChar == '\'' || lastChar == '"') { + endIndex--; + } + + return TokenParser.unescape(text, startIndex, endIndex, i -> i == firstChar); + } + + /** + * Checks if this tag part represents true. + * + * @return if this tag part represents true + * @since 4.10.0 + */ + public boolean isTrue() { + return "true".equals(this.value) || "on".equals(this.value); + } + + /** + * Checks if this tag part represents false. + * + * @return if this tag part represents false + * @since 4.10.0 + */ + public boolean isFalse() { + return "false".equals(this.value) || "off".equals(this.value); + } + + @Override + public String toString() { + return this.value; + } +} diff --git a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/parser/node/TextNode.java b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/parser/node/TextNode.java new file mode 100644 index 000000000..3a1a1b2eb --- /dev/null +++ b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/parser/node/TextNode.java @@ -0,0 +1,60 @@ +/* + * This file is part of adventure, licensed under the MIT License. + * + * Copyright (c) 2017-2021 KyoriPowered + * + * 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 net.kyori.adventure.text.minimessage.parser.node; + +import java.util.function.IntPredicate; +import net.kyori.adventure.text.minimessage.parser.Token; +import net.kyori.adventure.text.minimessage.parser.TokenParser; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Represents a string of chars. + * + * @since 4.10.0 + */ +public final class TextNode extends ValueNode { + private static final IntPredicate ESCAPES = i -> i == '<'; + + /** + * Creates a new text node. + * + * @param parent the parent of this node + * @param token the token that created this node + * @param sourceMessage the source message + * @since 4.10.0 + */ + public TextNode( + final @Nullable ElementNode parent, + final @NotNull Token token, + final @NotNull String sourceMessage + ) { + super(parent, token, sourceMessage, TokenParser.unescape(sourceMessage, token.startIndex(), token.endIndex(), ESCAPES)); + } + + @Override + String valueName() { + return "TextNode"; + } +} diff --git a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/parser/node/ValueNode.java b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/parser/node/ValueNode.java new file mode 100644 index 000000000..be560810e --- /dev/null +++ b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/parser/node/ValueNode.java @@ -0,0 +1,76 @@ +/* + * This file is part of adventure, licensed under the MIT License. + * + * Copyright (c) 2017-2021 KyoriPowered + * + * 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 net.kyori.adventure.text.minimessage.parser.node; + +import java.util.Objects; +import net.kyori.adventure.text.minimessage.parser.Token; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Represents a node in the tree which has a text value. + * + * @since 4.10.0 + */ + +public abstract class ValueNode extends ElementNode { + private final String value; + + /** + * Creates a new element node. + * + * @param parent the parent of this node + * @param token the token that created this node + * @param sourceMessage the source message + * @since 4.10.0 + */ + ValueNode(final @Nullable ElementNode parent, final @Nullable Token token, final @NotNull String sourceMessage, final @NotNull String value) { + super(parent, token, sourceMessage); + this.value = value; + } + + abstract String valueName(); + + /** + * Returns the value of this text node. + * + * @return the value + * @since 4.10.0 + */ + public @NotNull String value() { + return this.value; + } + + @Override + public @NotNull Token token() { + return Objects.requireNonNull(super.token(), "token is not set"); + } + + @Override + public @NotNull StringBuilder buildToString(final @NotNull StringBuilder sb, final int indent) { + final char[] in = this.ident(indent); + sb.append(in).append(this.valueName()).append("('").append(this.value).append("')\n"); + return sb; + } +} diff --git a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/parser/node/package-info.java b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/parser/node/package-info.java new file mode 100644 index 000000000..a35574ade --- /dev/null +++ b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/parser/node/package-info.java @@ -0,0 +1,27 @@ +/* + * This file is part of adventure, licensed under the MIT License. + * + * Copyright (c) 2017-2021 KyoriPowered + * + * 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. + */ +/** + * Parser nodes. + */ +package net.kyori.adventure.text.minimessage.parser.node; diff --git a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/parser/package-info.java b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/parser/package-info.java new file mode 100644 index 000000000..ad474e2e4 --- /dev/null +++ b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/parser/package-info.java @@ -0,0 +1,27 @@ +/* + * This file is part of adventure, licensed under the MIT License. + * + * Copyright (c) 2017-2021 KyoriPowered + * + * 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. + */ +/** + * Parser. + */ +package net.kyori.adventure.text.minimessage.parser; diff --git a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/placeholder/DynamicPlaceholderResolver.java b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/placeholder/DynamicPlaceholderResolver.java new file mode 100644 index 000000000..fe4c87786 --- /dev/null +++ b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/placeholder/DynamicPlaceholderResolver.java @@ -0,0 +1,55 @@ +/* + * This file is part of adventure, licensed under the MIT License. + * + * Copyright (c) 2017-2021 KyoriPowered + * + * 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 net.kyori.adventure.text.minimessage.placeholder; + +import java.util.function.Function; +import net.kyori.adventure.text.ComponentLike; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +final class DynamicPlaceholderResolver implements PlaceholderResolver { + private final Function resolver; + + DynamicPlaceholderResolver(final Function resolver) { + this.resolver = resolver; + } + + @Override + public boolean canResolve(final @NotNull String key) { + final Object result = this.resolver.apply(key); + return result instanceof String || result instanceof ComponentLike || result instanceof Placeholder; + } + + @Override + public @Nullable Placeholder resolve(final @NotNull String key) { + final Object result = this.resolver.apply(key); + + if (result == null) return null; + else if (result instanceof String) return Placeholder.placeholder(key, (String) result); + else if (result instanceof ComponentLike) return Placeholder.placeholder(key, (ComponentLike) result); + else if (result instanceof Placeholder) return (Placeholder) result; + + throw new IllegalArgumentException("Dynamic placeholder resolver must return instances of String or ComponentLike, instead found " + result.getClass().getName()); + } +} diff --git a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/placeholder/EmptyPlaceholderResolver.java b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/placeholder/EmptyPlaceholderResolver.java new file mode 100644 index 000000000..0510efb94 --- /dev/null +++ b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/placeholder/EmptyPlaceholderResolver.java @@ -0,0 +1,47 @@ +/* + * This file is part of adventure, licensed under the MIT License. + * + * Copyright (c) 2017-2021 KyoriPowered + * + * 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 net.kyori.adventure.text.minimessage.placeholder; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * An empty placeholder resolver that has no placeholders. + */ +final class EmptyPlaceholderResolver implements PlaceholderResolver { + static final EmptyPlaceholderResolver INSTANCE = new EmptyPlaceholderResolver(); + + private EmptyPlaceholderResolver() { + } + + @Override + public boolean canResolve(final @NotNull String key) { + return false; + } + + @Override + public @Nullable Placeholder resolve(final @NotNull String key) { + return null; + } +} diff --git a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/placeholder/FilteringPlaceholderResolver.java b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/placeholder/FilteringPlaceholderResolver.java new file mode 100644 index 000000000..d281ec77c --- /dev/null +++ b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/placeholder/FilteringPlaceholderResolver.java @@ -0,0 +1,50 @@ +/* + * This file is part of adventure, licensed under the MIT License. + * + * Copyright (c) 2017-2021 KyoriPowered + * + * 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 net.kyori.adventure.text.minimessage.placeholder; + +import java.util.function.Predicate; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +final class FilteringPlaceholderResolver implements PlaceholderResolver { + private final PlaceholderResolver placeholderResolver; + private final Predicate filter; + + FilteringPlaceholderResolver(final PlaceholderResolver placeholderResolver, final Predicate filter) { + this.placeholderResolver = placeholderResolver; + this.filter = filter; + } + + @Override + public boolean canResolve(final @NotNull String key) { + return this.resolve(key) != null; + } + + @Override + public @Nullable Placeholder resolve(final @NotNull String key) { + final Placeholder placeholder = this.placeholderResolver.resolve(key); + if (placeholder == null || this.filter.test(placeholder)) return null; + return placeholder; + } +} diff --git a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/placeholder/GroupedPlaceholderResolver.java b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/placeholder/GroupedPlaceholderResolver.java new file mode 100644 index 000000000..bc621a333 --- /dev/null +++ b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/placeholder/GroupedPlaceholderResolver.java @@ -0,0 +1,57 @@ +/* + * This file is part of adventure, licensed under the MIT License. + * + * Copyright (c) 2017-2021 KyoriPowered + * + * 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 net.kyori.adventure.text.minimessage.placeholder; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * A placeholder resolver that resolves from multiple sources. + */ +final class GroupedPlaceholderResolver implements PlaceholderResolver { + private final Iterable placeholderResolvers; + + GroupedPlaceholderResolver(final @NotNull Iterable placeholderResolvers) { + this.placeholderResolvers = placeholderResolvers; + } + + @Override + public boolean canResolve(final @NotNull String key) { + for (final PlaceholderResolver placeholderResolver : this.placeholderResolvers) { + if (placeholderResolver.canResolve(key)) return true; + } + + return false; + } + + @Override + public @Nullable Placeholder resolve(final @NotNull String key) { + for (final PlaceholderResolver placeholderResolver : this.placeholderResolvers) { + final Placeholder placeholder = placeholderResolver.resolve(key); + if (placeholder != null) return placeholder; + } + + return null; + } +} diff --git a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/placeholder/MapPlaceholderResolver.java b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/placeholder/MapPlaceholderResolver.java new file mode 100644 index 000000000..ae02123ee --- /dev/null +++ b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/placeholder/MapPlaceholderResolver.java @@ -0,0 +1,49 @@ +/* + * This file is part of adventure, licensed under the MIT License. + * + * Copyright (c) 2017-2021 KyoriPowered + * + * 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 net.kyori.adventure.text.minimessage.placeholder; + +import java.util.Map; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * A placeholder resolver based on a map. + */ +final class MapPlaceholderResolver implements PlaceholderResolver { + private final Map placeholderMap; + + MapPlaceholderResolver(final @NotNull Map placeholderMap) { + this.placeholderMap = placeholderMap; + } + + @Override + public boolean canResolve(final @NotNull String key) { + return this.placeholderMap.containsKey(key); + } + + @Override + public @Nullable Placeholder resolve(final @NotNull String key) { + return this.placeholderMap.get(key); + } +} diff --git a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/placeholder/Placeholder.java b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/placeholder/Placeholder.java new file mode 100644 index 000000000..f5e6fc632 --- /dev/null +++ b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/placeholder/Placeholder.java @@ -0,0 +1,218 @@ +/* + * This file is part of adventure, licensed under the MIT License. + * + * Copyright (c) 2017-2021 KyoriPowered + * + * 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 net.kyori.adventure.text.minimessage.placeholder; + +import java.util.Locale; +import java.util.function.Supplier; +import java.util.stream.Stream; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.ComponentLike; +import net.kyori.examination.Examinable; +import net.kyori.examination.ExaminableProperty; +import net.kyori.examination.string.StringExaminer; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; + +import static java.util.Objects.requireNonNull; + +/** + * A placeholder in a message, which can replace a tag with a component. + * + * @since 4.10.0 + */ +public interface Placeholder extends Examinable { + + /** + * Constructs a placeholder that gets replaced with a string. + * + * @param key the placeholder + * @param value the value to replace the key with + * @return the constructed placeholder + * @since 4.10.0 + */ + static @NotNull Placeholder placeholder(final @NotNull String key, final @NotNull String value) { + return new StringPlaceholder( + requireNonNull(key, "key"), + requireNonNull(value, "value") + ); + } + + /** + * Constructs a placeholder that gets replaced with a component. + * + * @param key the placeholder + * @param value the component to replace the key with + * @return the constructed placeholder + * @since 4.10.0 + */ + static @NotNull Placeholder placeholder(final @NotNull String key, final @NotNull ComponentLike value) { + return new ComponentPlaceholder( + requireNonNull(key, "key"), + requireNonNull(requireNonNull(value, "value").asComponent(), "value cannot resolve to null") + ); + } + + /** + * Constructs a placeholder that gets replaced with a component lazily. + * + * @param key the placeholder + * @param value the supplier that supplies the component to replace the key with + * @return the constructed placeholder + * @since 4.10.0 + */ + static @NotNull Placeholder placeholder(final @NotNull String key, final @NotNull Supplier value) { + return new LazyComponentPlaceholder( + requireNonNull(key, "key"), + requireNonNull(value, "value") + ); + } + + /** + * Get the key for this placeholder. + * + * @return the key + * @since 4.10.0 + */ + @NotNull String key(); + + /** + * Get the value for this placeholder. + * + * @return the value + * @since 4.10.0 + */ + @NotNull Object value(); + + /** + * A placeholder with a value that will be parsed as a MiniMessage string. + * + * @since 4.10.0 + */ + @ApiStatus.Internal + class StringPlaceholder implements Placeholder { + private final String key; + private final String value; + + StringPlaceholder(final @NotNull String key, final @NotNull String value) { + if (!key.toLowerCase(Locale.ROOT).equals(key)) + throw new IllegalArgumentException("Placeholder key '" + key + "' must be lowercase"); + this.key = key; + this.value = value; + } + + @Override + public @NotNull String key() { + return this.key; + } + + @Override + public @NotNull String value() { + return this.value; + } + + @Override + public final String toString() { + return this.examine(StringExaminer.simpleEscaping()); + } + + @Override + public @NotNull Stream examinableProperties() { + return Stream.of( + ExaminableProperty.of("key", this.key), + ExaminableProperty.of("value", this.value) + ); + } + } + + /** + * A placeholder with a {@link Component} value that will be inserted directly. + * + * @since 4.10.0 + */ + @ApiStatus.Internal + class ComponentPlaceholder implements Placeholder { + private final @NotNull String key; + private final @NotNull Component value; + + public ComponentPlaceholder(final @NotNull String key, final @NotNull Component value) { + if (!key.toLowerCase(Locale.ROOT).equals(key)) + throw new IllegalArgumentException("Placeholder key '" + key + "' must be lowercase"); + this.key = key; + this.value = value; + } + + @Override + public @NotNull String key() { + return this.key; + } + + @Override + public @NotNull Component value() { + return this.value; + } + + @Override + public final String toString() { + return this.examine(StringExaminer.simpleEscaping()); + } + + @Override + public @NotNull Stream examinableProperties() { + return Stream.of( + ExaminableProperty.of("key", this.key), + ExaminableProperty.of("value", this.value) + ); + } + } + + /** + * A placeholder with a lazily provided {@link Component} value that will be inserted directly. + * + * @since 4.10.0 + */ + @ApiStatus.Internal + class LazyComponentPlaceholder extends ComponentPlaceholder { + private final @NotNull Supplier value; + + public LazyComponentPlaceholder(final @NotNull String key, final @NotNull Supplier value) { + super(key, Component.empty()); + this.value = value; + } + + @Override + public @NotNull Component value() { + return requireNonNull(requireNonNull( + this.value.get(), () -> "get() value of " + this.value) + .asComponent(), () -> "asComponent() on value of " + this.value); + } + + @Override + public @NotNull Stream examinableProperties() { + return Stream.of( + ExaminableProperty.of("key", this.key()), + ExaminableProperty.of("value", this.value) + ); + } + } +} diff --git a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/placeholder/PlaceholderResolver.java b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/placeholder/PlaceholderResolver.java new file mode 100644 index 000000000..f1bfbef67 --- /dev/null +++ b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/placeholder/PlaceholderResolver.java @@ -0,0 +1,247 @@ +/* + * This file is part of adventure, licensed under the MIT License. + * + * Copyright (c) 2017-2021 KyoriPowered + * + * 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 net.kyori.adventure.text.minimessage.placeholder; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.Function; +import java.util.function.Predicate; +import net.kyori.adventure.text.ComponentLike; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * A resolver for user-defined placeholders. + * + * @since 4.10.0 + */ +public interface PlaceholderResolver { + /** + * Constructs a placeholder resolver from key-value pairs or {@link Placeholder} instances. + * + *

The {@code pairs} arguments must be a string key followed by a string or {@link ComponentLike} value or a {@link Placeholder}.

+ * + * @param objects the objects + * @return the placeholder resolver + * @since 4.10.0 + */ + static @NotNull PlaceholderResolver resolving(final @NotNull Object @NotNull ... objects) { + final int size = Objects.requireNonNull(objects, "pairs").length; + + if (size == 0) return empty(); + + String key = null; + + final Map placeholderMap = new HashMap<>(size); + for (int i = 0; i < size; i++) { + final Object obj = objects[i]; + + if (key == null) { + // we are looking for a key or a placeholder + if (obj instanceof Placeholder) { + final Placeholder placeholder = (Placeholder) obj; + placeholderMap.put(placeholder.key(), placeholder); + } else if (obj instanceof String) { + key = (String) obj; + } else { + throw new IllegalArgumentException("Argument " + i + " in pairs must be a String key or a Placeholder, was " + obj.getClass().getName()); + } + } else { + // we are looking for a value + if (obj instanceof String) { + placeholderMap.put(key, Placeholder.placeholder(key, (String) obj)); + } else if (obj instanceof ComponentLike) { + placeholderMap.put(key, Placeholder.placeholder(key, (ComponentLike) obj)); + } else { + throw new IllegalArgumentException("Argument " + i + " in pairs must be a String or ComponentLike value, was " + obj.getClass().getName()); + } + + key = null; + } + } + + if (key != null) { + throw new IllegalArgumentException("Found key \"" + key + "\" in objects that wasn't followed by a value."); + } + + if (placeholderMap.isEmpty()) return empty(); + + return new MapPlaceholderResolver(placeholderMap); + } + + /** + * Constructs a placeholder resolver from key-value pairs. + * + *

The values must be instances of String, {@link ComponentLike} or {@link Placeholder}.

+ * + * @param pairs the key-value pairs + * @return the placeholder resolver + * @since 4.10.0 + */ + static @NotNull PlaceholderResolver pairs(final @NotNull Map pairs) { + final int size = Objects.requireNonNull(pairs, "pairs").size(); + + if (size == 0) return empty(); + + final Map placeholderMap = new HashMap<>(size); + + for (final Map.Entry entry : pairs.entrySet()) { + final String key = Objects.requireNonNull(entry.getKey(), "pairs cannot contain null keys"); + final Object value = entry.getValue(); + + if (value instanceof String) placeholderMap.put(key, Placeholder.placeholder(key, (String) value)); + else if (value instanceof ComponentLike) placeholderMap.put(key, Placeholder.placeholder(key, (ComponentLike) value)); + else if (value instanceof Placeholder) placeholderMap.put(key, (Placeholder) value); + else + throw new IllegalArgumentException("Values must be either ComponentLike or String but " + value + " was not."); + } + + return new MapPlaceholderResolver(placeholderMap); + } + + /** + * Constructs a placeholder resolver from some placeholders. + * + * @param placeholders the placeholders + * @return the placeholder resolver + * @since 4.10.0 + */ + static @NotNull PlaceholderResolver placeholders(final @NotNull Placeholder @NotNull ... placeholders) { + if (Objects.requireNonNull(placeholders, "placeholders").length == 0) return empty(); + return placeholders(Arrays.asList(placeholders)); + } + + /** + * Constructs a placeholder resolver from some placeholders. + * + * @param placeholders the placeholders + * @return the placeholder resolver + * @since 4.10.0 + */ + static @NotNull PlaceholderResolver placeholders(final @NotNull Iterable placeholders) { + final Map placeholderMap = new HashMap<>(); + + for (final Placeholder placeholder : Objects.requireNonNull(placeholders, "placeholders")) { + Objects.requireNonNull(placeholder, "placeholders must not contain null elements"); + placeholderMap.put(placeholder.key(), placeholder); + } + + if (placeholderMap.isEmpty()) return empty(); + + return new MapPlaceholderResolver(placeholderMap); + } + + /** + * Constructs a placeholder resolver capable of resolving from multiple sources. + * + * @param placeholderResolvers the placeholder resolvers + * @return the placeholder resolver + * @since 4.10.0 + */ + static @NotNull PlaceholderResolver combining(final @NotNull PlaceholderResolver @NotNull ... placeholderResolvers) { + if (Objects.requireNonNull(placeholderResolvers, "placeholderResolvers").length == 1) + return Objects.requireNonNull(placeholderResolvers[0], "placeholderResolvers must not contain null elements"); + return new GroupedPlaceholderResolver(Arrays.asList(placeholderResolvers)); + } + + /** + * Constructs a placeholder resolver capable of resolving from multiple sources, in iteration order. + * + *

The provided iterable is copied. This means changes to the iterable will not reflect in the returned resolver.

+ * + * @param placeholderResolvers the placeholder resolvers + * @return the placeholder resolver + * @since 4.10.0 + */ + static @NotNull PlaceholderResolver combining(final @NotNull Iterable placeholderResolvers) { + final List placeholderResolverList = new ArrayList<>(); + + for (final PlaceholderResolver placeholderResolver : Objects.requireNonNull(placeholderResolvers, "placeholderResolvers")) { + placeholderResolverList.add(Objects.requireNonNull(placeholderResolver, "placeholderResolvers cannot contain null elements")); + } + + final int size = placeholderResolverList.size(); + if (size == 0) return empty(); + if (size == 1) return placeholderResolverList.get(0); + return new GroupedPlaceholderResolver(placeholderResolvers); + } + + /** + * Constructs a placeholder resolver capable of dynamically resolving placeholders. + * + *

The {@code resolver} function must return instances of String, {@link ComponentLike} or {@link Placeholder}. + * The resolver can return {@code null} to indicate it cannot resolve a placeholder.

+ * + * @param resolver the resolver + * @return the placeholder resolver + * @since 4.10.0 + */ + static @NotNull PlaceholderResolver dynamic(final @NotNull Function resolver) { + return new DynamicPlaceholderResolver(Objects.requireNonNull(resolver, "resolver")); + } + + /** + * Constructs a placeholder resolver that uses the provided filter to prevent the resolving of placeholders that match the filter. + * + * @param placeholderResolver the placeholder resolver + * @param filter the filter + * @return the placeholder resolver + * @since 4.10.0 + */ + static @NotNull PlaceholderResolver filtering(final @NotNull PlaceholderResolver placeholderResolver, final @NotNull Predicate filter) { + return new FilteringPlaceholderResolver(Objects.requireNonNull(placeholderResolver, "placeholderResolver"), Objects.requireNonNull(filter, "filter")); + } + + /** + * An empty placeholder resolver that will return {@code null} for all resolve attempts. + * + * @return the placeholder resolver + * @since 4.10.0 + */ + static @NotNull PlaceholderResolver empty() { + return EmptyPlaceholderResolver.INSTANCE; + } + + /** + * Checks if this placeholder resolver can resolve a placeholder from a key. + * + * @param key the key + * @return if a placeholder can be resolved from this key + * @since 4.10.0 + */ + boolean canResolve(final @NotNull String key); + + /** + * Returns a placeholder from a given key, if any exist. + * + * @param key the key + * @return the placeholder + * @since 4.10.0 + */ + @Nullable Placeholder resolve(final @NotNull String key); +} diff --git a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/placeholder/package-info.java b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/placeholder/package-info.java new file mode 100644 index 000000000..7da421445 --- /dev/null +++ b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/placeholder/package-info.java @@ -0,0 +1,27 @@ +/* + * This file is part of adventure, licensed under the MIT License. + * + * Copyright (c) 2017-2021 KyoriPowered + * + * 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. + */ +/** + * Placeholders. + */ +package net.kyori.adventure.text.minimessage.placeholder; diff --git a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/transformation/Inserting.java b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/transformation/Inserting.java new file mode 100644 index 000000000..695a78e3a --- /dev/null +++ b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/transformation/Inserting.java @@ -0,0 +1,32 @@ +/* + * This file is part of adventure, licensed under the MIT License. + * + * Copyright (c) 2017-2021 KyoriPowered + * + * 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 net.kyori.adventure.text.minimessage.transformation; + +/** + * Marker interface for transformations that insert text or components, but have no children. + * + * @since 4.10.0 + */ +public interface Inserting { +} diff --git a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/transformation/Modifying.java b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/transformation/Modifying.java new file mode 100644 index 000000000..488693029 --- /dev/null +++ b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/transformation/Modifying.java @@ -0,0 +1,54 @@ +/* + * This file is part of adventure, licensed under the MIT License. + * + * Copyright (c) 2017-2021 KyoriPowered + * + * 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 net.kyori.adventure.text.minimessage.transformation; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.minimessage.parser.node.ElementNode; + +/** + * Transformations implementing this interface can transform a whole subtree of nodes. + * + * @since 4.10.0 + */ +public interface Modifying { + + /** + * This method gets called once for every element in the sub tree, allowing you to do calculations beforehand. + * + * @param curr the current element in the sub tree + * @since 4.10.0 + */ + void visit(ElementNode curr); + + /** + * Applies this transformation for the current component. + * This gets called after the component tree has been assembled, but you are free to modify it however you like. + * + * @param curr the current component + * @param depth the depth of the tree the current component is at + * @return the new parent + * @since 4.10.0 + */ + Component apply(Component curr, int depth); +} diff --git a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/transformation/Transformation.java b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/transformation/Transformation.java new file mode 100644 index 000000000..0eae056c0 --- /dev/null +++ b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/transformation/Transformation.java @@ -0,0 +1,61 @@ +/* + * This file is part of adventure, licensed under the MIT License. + * + * Copyright (c) 2017-2021 KyoriPowered + * + * 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 net.kyori.adventure.text.minimessage.transformation; + +import net.kyori.adventure.text.Component; +import net.kyori.examination.Examinable; +import net.kyori.examination.string.StringExaminer; + +/** + * A transformation that can be applied while parsing a message. + * + *

A transformation instance is created for each instance of a tag in a parsed string.

+ * + * @see TransformationRegistry to access and register available transformations + * @since 4.10.0 + */ +public abstract class Transformation implements Examinable { + + protected Transformation() { + } + + /** + * Return a transformed {@code component} based on the applied parameters. + * + * @return the transformed component + * @since 4.10.0 + */ + public abstract Component apply(); + + @Override + public final String toString() { + return this.examine(StringExaminer.simpleEscaping()); + } + + @Override + public abstract boolean equals(final Object o); + + @Override + public abstract int hashCode(); +} diff --git a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/transformation/TransformationFactory.java b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/transformation/TransformationFactory.java new file mode 100644 index 000000000..dae382f2f --- /dev/null +++ b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/transformation/TransformationFactory.java @@ -0,0 +1,72 @@ +/* + * This file is part of adventure, licensed under the MIT License. + * + * Copyright (c) 2017-2021 KyoriPowered + * + * 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 net.kyori.adventure.text.minimessage.transformation; + +import java.util.List; +import net.kyori.adventure.text.minimessage.Context; +import net.kyori.adventure.text.minimessage.parser.node.TagPart; + +/** + * A supplier of new transformation instances. + * + * @param the transformation type + * @since 4.10.0 + */ +@FunctionalInterface +public interface TransformationFactory { + /** + * Produce a new instance of the transformation given a specific tag. + * + * @param ctx the parse context + * @param name the tag name + * @param args an unmodifiable list of tag arguments, may be empty + * @return the new instance + * @since 4.10.0 + */ + T parse(final Context ctx, final String name, final List args); + + /** + * A variant of a transformation factory that doesn't take a context object. + * + * @param the transformation type + * @since 4.10.0 + */ + @FunctionalInterface + interface ContextFree extends TransformationFactory { + @Override + default T parse(final Context ctx, final String name, final List args) { + return this.parse(name, args); + } + + /** + * Produce a new instance of the transformation given a specific tag. + * + * @param name the tag name + * @param args an unmodifiable list of tag arguments, may be empty + * @return the new instance + * @since 4.10.0 + */ + T parse(final String name, final List args); + } +} diff --git a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/transformation/TransformationRegistry.java b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/transformation/TransformationRegistry.java new file mode 100644 index 000000000..1916d8601 --- /dev/null +++ b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/transformation/TransformationRegistry.java @@ -0,0 +1,134 @@ +/* + * This file is part of adventure, licensed under the MIT License. + * + * Copyright (c) 2017-2021 KyoriPowered + * + * 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 net.kyori.adventure.text.minimessage.transformation; + +import java.util.List; +import net.kyori.adventure.text.minimessage.Context; +import net.kyori.adventure.text.minimessage.parser.node.TagPart; +import net.kyori.adventure.text.minimessage.placeholder.PlaceholderResolver; +import net.kyori.adventure.util.Buildable; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * A registry of transformation types understood by the MiniMessage parser. + * + * @since 4.10.0 + */ +public interface TransformationRegistry extends Buildable { + + /** + * Gets a transformation from this registry based on the current state. + * + * @param name the tag name + * @param inners the tokens that make up the tag arguments + * @param placeholderResolver the placeholder resolver + * @param context the debug context + * @return a possible transformation + * @since 4.10.0 + */ + @Nullable Transformation get(final String name, final List inners, final PlaceholderResolver placeholderResolver, final Context context); + + /** + * Tests if any registered transformation type matches the provided key. + * + * @param name the tag name + * @return whether any transformation type exists + * @since 4.10.0 + */ + boolean exists(final String name); + + /** + * Tests if any registered transformation type matches the provided key. + * + * @param name the tag name + * @param placeholderResolver the resolver to resolve other component types + * @return whether any transformation exists + * @since 4.10.0 + */ + boolean exists(final String name, final PlaceholderResolver placeholderResolver); + + /** + * Creates a new {@link TransformationRegistry.Builder}. + * + * @return a builder + * @since 4.10.0 + */ + static @NotNull Builder builder() { + return new TransformationRegistryImpl.BuilderImpl(); + } + + /** + * Gets an instance of the transformation registry without any transformations. + * + * @return a empty transformation registry + * @since 4.10.0 + */ + static @NotNull TransformationRegistry empty() { + return TransformationRegistryImpl.EMPTY; + } + + /** + * Gets an instance of the transformation registry with only the standard transformations. + * + * @return a standard transformation registry + * @since 4.10.0 + */ + static @NotNull TransformationRegistry standard() { + return TransformationRegistryImpl.STANDARD; + } + + /** + * A builder for {@link TransformationRegistry}. + * + * @since 4.10.0 + */ + interface Builder extends Buildable.Builder { + + /** + * Clears all currently set transformations. + * + * @return this builder + * @since 4.10.0 + */ + @NotNull Builder clear(); + + /** + * Adds a supplied transformation to the registry. + * + * @return this builder + * @since 4.10.0 + */ + @NotNull Builder add(final @NotNull TransformationType transformation); + + /** + * Adds the supplied transformations to the registry. + * + * @return this builder + * @since 4.10.0 + */ + @NotNull Builder add(final @NotNull TransformationType... transformations); + + } +} diff --git a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/transformation/TransformationRegistryImpl.java b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/transformation/TransformationRegistryImpl.java new file mode 100644 index 000000000..5b67e8df7 --- /dev/null +++ b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/transformation/TransformationRegistryImpl.java @@ -0,0 +1,165 @@ +/* + * This file is part of adventure, licensed under the MIT License. + * + * Copyright (c) 2017-2021 KyoriPowered + * + * 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 net.kyori.adventure.text.minimessage.transformation; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import net.kyori.adventure.text.minimessage.Context; +import net.kyori.adventure.text.minimessage.parser.ParsingException; +import net.kyori.adventure.text.minimessage.parser.node.TagPart; +import net.kyori.adventure.text.minimessage.placeholder.Placeholder; +import net.kyori.adventure.text.minimessage.placeholder.Placeholder.ComponentPlaceholder; +import net.kyori.adventure.text.minimessage.placeholder.PlaceholderResolver; +import net.kyori.adventure.text.minimessage.transformation.inbuild.PlaceholderTransformation; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +final class TransformationRegistryImpl implements TransformationRegistry { + + private static final List> DEFAULT_TRANSFORMATIONS = new ArrayList<>(); + + static final TransformationRegistry EMPTY; + static final TransformationRegistry STANDARD; + + static { + DEFAULT_TRANSFORMATIONS.add(TransformationType.COLOR); + DEFAULT_TRANSFORMATIONS.add(TransformationType.DECORATION); + DEFAULT_TRANSFORMATIONS.add(TransformationType.HOVER_EVENT); + DEFAULT_TRANSFORMATIONS.add(TransformationType.CLICK_EVENT); + DEFAULT_TRANSFORMATIONS.add(TransformationType.KEYBIND); + DEFAULT_TRANSFORMATIONS.add(TransformationType.TRANSLATABLE); + DEFAULT_TRANSFORMATIONS.add(TransformationType.INSERTION); + DEFAULT_TRANSFORMATIONS.add(TransformationType.FONT); + DEFAULT_TRANSFORMATIONS.add(TransformationType.GRADIENT); + DEFAULT_TRANSFORMATIONS.add(TransformationType.RAINBOW); + + EMPTY = new TransformationRegistryImpl(Collections.emptyList()); + STANDARD = TransformationRegistry.builder().build(); + } + + private final List> types; + + /** + * Create a transformation registry with the specified transformation types. + * + * @param types known transformation types + * @since 4.10.0 + */ + TransformationRegistryImpl(final List> types) { + this.types = Collections.unmodifiableList(types); + } + + private Transformation tryLoad(final TransformationFactory factory, final String name, final List inners, final Context context) { + try { + return factory.parse(context, name, inners.subList(1, inners.size())); + } catch (final ParsingException exception) { + exception.originalText(context.originalMessage()); + throw exception; + } + } + + @Override + public @Nullable Transformation get(final String name, final List inners, final PlaceholderResolver placeholderResolver, final Context context) { + // first try if we have a custom placeholder resolver + final Placeholder placeholder = placeholderResolver.resolve(name); + if (placeholder != null) { + // The parser handles StringPlaceholders + if (placeholder instanceof ComponentPlaceholder) { + return this.tryLoad(PlaceholderTransformation.factory(new ComponentPlaceholder(name, ((ComponentPlaceholder) placeholder).value())), name, inners, context); + } + } + // then check our registry + for (final TransformationType type : this.types) { + if (type.canParse.test(name)) { + return this.tryLoad(type.factory, name, inners, context); + } + } + + return null; + } + + @Override + public boolean exists(final String name) { + for (final TransformationType type : this.types) { + if (type.canParse.test(name)) { + return true; + } + } + return false; + } + + @Override + public boolean exists(final String name, final PlaceholderResolver placeholderResolver) { + // first check the placeholder resolver + if (placeholderResolver.canResolve(name)) { + return true; + } + // then check registry + return this.exists(name); + } + + @Override + public @NotNull TransformationRegistry.Builder toBuilder() { + return new TransformationRegistryImpl.BuilderImpl(this); + } + + static final class BuilderImpl implements TransformationRegistry.Builder { + + private final List> types; + + BuilderImpl() { + this.types = new ArrayList<>(DEFAULT_TRANSFORMATIONS); + } + + BuilderImpl(final TransformationRegistryImpl registry) { + this.types = new ArrayList<>(registry.types); + } + + @Override + public @NotNull Builder clear() { + this.types.clear(); + return this; + } + + @Override + public @NotNull Builder add(final @NotNull TransformationType transformation) { + this.types.add(transformation); + return this; + } + + @SafeVarargs + @Override + public final @NotNull Builder add(final @NotNull TransformationType... transformations) { + Collections.addAll(this.types, transformations); + return this; + } + + @Override + public @NotNull TransformationRegistry build() { + return new TransformationRegistryImpl(this.types); + } + } + +} diff --git a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/transformation/TransformationType.java b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/transformation/TransformationType.java new file mode 100644 index 000000000..ae5e9b3bd --- /dev/null +++ b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/transformation/TransformationType.java @@ -0,0 +1,186 @@ +/* + * This file is part of adventure, licensed under the MIT License. + * + * Copyright (c) 2017-2021 KyoriPowered + * + * 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 net.kyori.adventure.text.minimessage.transformation; + +import java.util.Arrays; +import java.util.Collection; +import java.util.HashSet; +import java.util.Locale; +import java.util.Set; +import java.util.function.Predicate; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import net.kyori.adventure.text.format.TextDecoration; +import net.kyori.adventure.text.minimessage.Tokens; +import net.kyori.adventure.text.minimessage.transformation.inbuild.ClickTransformation; +import net.kyori.adventure.text.minimessage.transformation.inbuild.ColorTransformation; +import net.kyori.adventure.text.minimessage.transformation.inbuild.DecorationTransformation; +import net.kyori.adventure.text.minimessage.transformation.inbuild.FontTransformation; +import net.kyori.adventure.text.minimessage.transformation.inbuild.GradientTransformation; +import net.kyori.adventure.text.minimessage.transformation.inbuild.HoverTransformation; +import net.kyori.adventure.text.minimessage.transformation.inbuild.InsertionTransformation; +import net.kyori.adventure.text.minimessage.transformation.inbuild.KeybindTransformation; +import net.kyori.adventure.text.minimessage.transformation.inbuild.RainbowTransformation; +import net.kyori.adventure.text.minimessage.transformation.inbuild.TranslatableTransformation; + +import static java.util.Objects.requireNonNull; + +/** + * Available types of transformation. + * + * @param transformation class + * @since 4.10.0 + */ +public final class TransformationType { + + public static final TransformationType COLOR = transformationType( + ColorTransformation::canParse, + ColorTransformation::create + ); + public static final TransformationType DECORATION = transformationType( + acceptingNames( + Stream.of(TextDecoration.NAMES.keys(), DecorationTransformation.DECORATION_ALIASES.keySet()) + .flatMap(Collection::stream) + .flatMap(k -> Stream.of(k, DecorationTransformation.REVERT + k)) + .collect(Collectors.toSet()) + ), + DecorationTransformation::create + ); + public static final TransformationType HOVER_EVENT = transformationType( + acceptingNames(Tokens.HOVER), + HoverTransformation::create + ); + public static final TransformationType CLICK_EVENT = transformationType( + acceptingNames(Tokens.CLICK), + ClickTransformation::create + ); + public static final TransformationType KEYBIND = transformationType( + acceptingNames(Tokens.KEYBIND), + KeybindTransformation::create + ); + public static final TransformationType TRANSLATABLE = transformationType( + acceptingNames(Tokens.TRANSLATABLE, Tokens.TRANSLATABLE_2, Tokens.TRANSLATABLE_3), + TranslatableTransformation::create + ); + public static final TransformationType INSERTION = transformationType( + acceptingNames(Tokens.INSERTION), + InsertionTransformation::create + ); + public static final TransformationType FONT = transformationType( + acceptingNames(Tokens.FONT), + FontTransformation::create + ); + public static final TransformationType GRADIENT = transformationType( + acceptingNames(Tokens.GRADIENT), + GradientTransformation::create + ); + public static final TransformationType RAINBOW = transformationType( + acceptingNames(Tokens.RAINBOW), + RainbowTransformation::create + ); + + final Predicate canParse; + final TransformationFactory factory; + + /** + * Constructs a new transformation type. + * + * @param canParse the predicate used to check if a tag can be parsed by this type + * @param factory the factory that should be used to create this type + * @since 4.10.0 + */ + private TransformationType(final Predicate canParse, final TransformationFactory factory) { + this.canParse = canParse; + this.factory = factory; + } + + /** + * Create a new transformation type with dynamically determined names. + * + *

It is assumed that the {@code nameMatcher} function is side-effect free, meaning that any time + * it is called for a certain input {@code x}, it will always return the same value, and not modify any other state.

+ * + *

All input to the {@code nameMatcher} function will be lower-case in the {@code ROOT} locale.

+ * + * @param nameMatcher the name matcher predicate + * @param factory a factory + * @param transformation instance type + * @return a new transformation type definition + * @since 4.10.0 + */ + public static TransformationType transformationType(final Predicate nameMatcher, final TransformationFactory factory) { + return new TransformationType<>( + requireNonNull(nameMatcher, "nameMatcher"), + requireNonNull(factory, "factory") + ); + } + + /** + * Create a new transformation type that can be parsed without performing recursive parses. + * + * @param nameMatcher filter for tag names to apply this transformation to + * @param contextFreeFactory the context-free factory + * @param transformation instance type + * @return a new transformation type definition + * @see #transformationType(Predicate, TransformationFactory) + * @since 4.10.0 + */ + public static TransformationType transformationType(final Predicate nameMatcher, final TransformationFactory.ContextFree contextFreeFactory) { + return new TransformationType<>( + requireNonNull(nameMatcher, "nameMatcher"), + requireNonNull(contextFreeFactory, "contextFreeFactory") + ); + } + + /** + * Create a name matcher function that will accept the provided lowercase tag names. + * + * @param elements the accepted names + * @return a name matcher function + * @since 4.10.0 + */ + public static Predicate acceptingNames(final String... elements) { + return acceptingNames(Arrays.asList(elements)); + } + + /** + * Create a name matcher function that will accept the provided lowercase tag names. + * + * @param elements the accepted names + * @return a name matcher function + * @since 4.10.0 + */ + public static Predicate acceptingNames(final Collection elements) { + if (elements.size() == 1) { + final String name = elements.iterator().next().toLowerCase(Locale.ROOT); + return tag -> tag.equals(name); + } else { + final Set names = new HashSet<>(elements.size()); + for (final String name : elements) { + names.add(name.toLowerCase(Locale.ROOT)); + } + return names::contains; + } + } +} diff --git a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/transformation/inbuild/ClickTransformation.java b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/transformation/inbuild/ClickTransformation.java new file mode 100644 index 000000000..cf9b9a3d2 --- /dev/null +++ b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/transformation/inbuild/ClickTransformation.java @@ -0,0 +1,99 @@ +/* + * This file is part of adventure, licensed under the MIT License. + * + * Copyright (c) 2017-2021 KyoriPowered + * + * 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 net.kyori.adventure.text.minimessage.transformation.inbuild; + +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.stream.Stream; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.event.ClickEvent; +import net.kyori.adventure.text.minimessage.parser.ParsingException; +import net.kyori.adventure.text.minimessage.parser.node.TagPart; +import net.kyori.adventure.text.minimessage.transformation.Transformation; +import net.kyori.examination.ExaminableProperty; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * A transformation applying a click event. + * + * @since 4.10.0 + */ +public final class ClickTransformation extends Transformation { + private final ClickEvent.Action action; + private final String value; + + /** + * Create a new click event transformation. + * + * @param name the tag name + * @param args tag arguments + * @return a new transformation + * @since 4.10.0 + */ + public static ClickTransformation create(final String name, final List args) { + if (args.size() != 2) { + throw new ParsingException("Don't know how to turn " + args + " into a click event", args); + } + final ClickEvent.@Nullable Action action = ClickEvent.Action.NAMES.value(args.get(0).value().toLowerCase(Locale.ROOT)); + final String value = args.get(1).value(); + if (action == null) { + throw new ParsingException("Unknown click event action '" + args.get(0).value() + "'", args); + } + + return new ClickTransformation(action, value); + } + + private ClickTransformation(final ClickEvent.Action action, final String value) { + this.action = action; + this.value = value; + } + + @Override + public Component apply() { + return Component.empty().clickEvent(ClickEvent.clickEvent(this.action, this.value)); + } + + @Override + public @NotNull Stream examinableProperties() { + return Stream.of( + ExaminableProperty.of("action", this.action), + ExaminableProperty.of("value", this.value) + ); + } + + @Override + public boolean equals(final Object other) { + if (this == other) return true; + if (other == null || this.getClass() != other.getClass()) return false; + final ClickTransformation that = (ClickTransformation) other; + return this.action == that.action && Objects.equals(this.value, that.value); + } + + @Override + public int hashCode() { + return Objects.hash(this.action, this.value); + } +} diff --git a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/transformation/inbuild/ColorTransformation.java b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/transformation/inbuild/ColorTransformation.java new file mode 100644 index 000000000..79666c9ef --- /dev/null +++ b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/transformation/inbuild/ColorTransformation.java @@ -0,0 +1,137 @@ +/* + * This file is part of adventure, licensed under the MIT License. + * + * Copyright (c) 2017-2021 KyoriPowered + * + * 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 net.kyori.adventure.text.minimessage.transformation.inbuild; + +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Stream; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.format.TextColor; +import net.kyori.adventure.text.minimessage.Tokens; +import net.kyori.adventure.text.minimessage.parser.ParsingException; +import net.kyori.adventure.text.minimessage.parser.node.TagPart; +import net.kyori.adventure.text.minimessage.transformation.Transformation; +import net.kyori.examination.ExaminableProperty; +import org.jetbrains.annotations.NotNull; + +/** + * A transformation applying a single text color. + * + * @since 4.10.0 + */ +public final class ColorTransformation extends Transformation { + private static final Map COLOR_ALIASES = new HashMap<>(); + + static { + COLOR_ALIASES.put("dark_grey", NamedTextColor.DARK_GRAY); + COLOR_ALIASES.put("grey", NamedTextColor.GRAY); + } + + private final TextColor color; + + private static boolean isColorOrAbbreviation(final String name) { + return name.equals(Tokens.COLOR) || name.equals(Tokens.COLOR_2) || name.equals(Tokens.COLOR_3); + } + + /** + * Get if this transformation can handle the provided tag name. + * + * @param name tag name to test + * @return if this transformation is applicable + * @since 4.10.0 + */ + public static boolean canParse(final String name) { + return isColorOrAbbreviation(name) + || TextColor.fromHexString(name) != null + || NamedTextColor.NAMES.value(name) != null + || COLOR_ALIASES.containsKey(name); + } + + /** + * Create a new color name. + * + * @param name the tag name + * @param args the tag arguments + * @return a new transformation + * @since 4.10.0 + */ + public static ColorTransformation create(final String name, final List args) { + final String colorName; + if (isColorOrAbbreviation(name)) { + if (args.size() == 1) { + colorName = args.get(0).value().toLowerCase(Locale.ROOT); + } else { + throw new ParsingException("Expected to find a color parameter, but found " + args, args); + } + } else { + colorName = name; + } + + final TextColor color; + if (COLOR_ALIASES.containsKey(colorName)) { + color = COLOR_ALIASES.get(colorName); + } else if (colorName.charAt(0) == '#') { + color = TextColor.fromHexString(colorName); + } else { + color = NamedTextColor.NAMES.value(colorName); + } + + if (color == null) { + throw new ParsingException("Don't know how to turn '" + name + "' into a color", args); + } + + return new ColorTransformation(color); + } + + private ColorTransformation(final TextColor color) { + this.color = color; + } + + @Override + public Component apply() { + return Component.empty().color(this.color); + } + + @Override + public @NotNull Stream examinableProperties() { + return Stream.of(ExaminableProperty.of("color", this.color)); + } + + @Override + public boolean equals(final Object other) { + if (this == other) return true; + if (other == null || this.getClass() != other.getClass()) return false; + final ColorTransformation that = (ColorTransformation) other; + return Objects.equals(this.color, that.color); + } + + @Override + public int hashCode() { + return Objects.hash(this.color); + } +} diff --git a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/transformation/inbuild/DecorationTransformation.java b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/transformation/inbuild/DecorationTransformation.java new file mode 100644 index 000000000..bda60fb14 --- /dev/null +++ b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/transformation/inbuild/DecorationTransformation.java @@ -0,0 +1,132 @@ +/* + * This file is part of adventure, licensed under the MIT License. + * + * Copyright (c) 2017-2021 KyoriPowered + * + * 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 net.kyori.adventure.text.minimessage.transformation.inbuild; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Stream; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.TextDecoration; +import net.kyori.adventure.text.minimessage.Tokens; +import net.kyori.adventure.text.minimessage.parser.ParsingException; +import net.kyori.adventure.text.minimessage.parser.node.TagPart; +import net.kyori.adventure.text.minimessage.transformation.Transformation; +import net.kyori.examination.ExaminableProperty; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * A transformation that applies any {@link TextDecoration}. + * + * @since 4.10.0 + */ +public final class DecorationTransformation extends Transformation { + + public static final String REVERT = "!"; + + /** + * An unmodifiable map of known decoration aliases. + * + * @since 4.10.0 + */ + public static final Map DECORATION_ALIASES; + + static { + final Map aliases = new HashMap<>(); + aliases.put(Tokens.BOLD_2, TextDecoration.BOLD); + aliases.put(Tokens.ITALIC_2, TextDecoration.ITALIC); + aliases.put(Tokens.ITALIC_3, TextDecoration.ITALIC); + aliases.put(Tokens.UNDERLINED_2, TextDecoration.UNDERLINED); + aliases.put(Tokens.STRIKETHROUGH_2, TextDecoration.STRIKETHROUGH); + aliases.put(Tokens.OBFUSCATED_2, TextDecoration.OBFUSCATED); + DECORATION_ALIASES = Collections.unmodifiableMap(aliases); + } + + private final TextDecoration decoration; + private final boolean flag; + + /** + * Create a new decoration. + * + * @param name the tag name + * @param args the tag arguments + * @return a new transformation + * @since 4.10.0 + */ + public static DecorationTransformation create(String name, final List args) { + boolean flag = args.size() != 1 || !args.get(0).isFalse(); + + if (name.startsWith(REVERT)) { + if (args.size() == 1) { + throw new ParsingException("Can't use both ! short hand and a argument for decoration transformations!", args); + } + flag = false; + name = name.substring(1); + } + + final @Nullable TextDecoration decoration = parseDecoration(name); + + if (decoration == null) { + throw new ParsingException("Don't know how to turn '" + name + "' into a decoration", args); + } + + return new DecorationTransformation(decoration, flag); + } + + private static TextDecoration parseDecoration(final String name) { + final TextDecoration alias = DECORATION_ALIASES.get(name); + return alias != null ? alias : TextDecoration.NAMES.value(name); + } + + private DecorationTransformation(final TextDecoration decoration, final boolean flag) { + this.decoration = decoration; + this.flag = flag; + } + + @Override + public Component apply() { + return Component.empty().decoration(this.decoration, this.flag); + } + + @Override + public @NotNull Stream examinableProperties() { + return Stream.of(ExaminableProperty.of("decoration", this.decoration)); + } + + @Override + public boolean equals(final Object other) { + if (this == other) return true; + if (other == null || this.getClass() != other.getClass()) return false; + final DecorationTransformation that = (DecorationTransformation) other; + return this.decoration == that.decoration; + } + + @Override + public int hashCode() { + return Objects.hash(this.decoration); + } +} diff --git a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/transformation/inbuild/FontTransformation.java b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/transformation/inbuild/FontTransformation.java new file mode 100644 index 000000000..bae6f683a --- /dev/null +++ b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/transformation/inbuild/FontTransformation.java @@ -0,0 +1,97 @@ +/* + * This file is part of adventure, licensed under the MIT License. + * + * Copyright (c) 2017-2021 KyoriPowered + * + * 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 net.kyori.adventure.text.minimessage.transformation.inbuild; + +import java.util.List; +import java.util.Objects; +import java.util.stream.Stream; +import net.kyori.adventure.key.Key; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.Style; +import net.kyori.adventure.text.minimessage.parser.ParsingException; +import net.kyori.adventure.text.minimessage.parser.node.TagPart; +import net.kyori.adventure.text.minimessage.transformation.Transformation; +import net.kyori.examination.ExaminableProperty; +import org.intellij.lang.annotations.Subst; +import org.jetbrains.annotations.NotNull; + +/** + * A decoration that applies a font name. + * + * @since 4.10.0 + */ +public final class FontTransformation extends Transformation { + private final Key font; + + /** + * Create a new font transformation from a tag. + * + * @param name the tag name + * @param args the tag arguments + * @return a new transformation + * @since 4.10.0 + */ + public static FontTransformation create(final String name, final List args) { + final Key font; + if (args.size() == 1) { + @Subst("empty") final String fontKey = args.get(0).value(); + font = Key.key(fontKey); + } else if (args.size() == 2) { + @Subst(Key.MINECRAFT_NAMESPACE) final String namespaceKey = args.get(0).value(); + @Subst("empty") final String fontKey = args.get(1).value(); + font = Key.key(namespaceKey, fontKey); + } else { + throw new ParsingException("Don't know how to turn " + args + " into a font", args); + } + + return new FontTransformation(font); + } + + private FontTransformation(final Key font) { + this.font = font; + } + + @Override + public Component apply() { + return Component.empty().style(Style.style().font(this.font)); + } + + @Override + public @NotNull Stream examinableProperties() { + return Stream.of(ExaminableProperty.of("font", this.font)); + } + + @Override + public boolean equals(final Object other) { + if (this == other) return true; + if (other == null || this.getClass() != other.getClass()) return false; + final FontTransformation that = (FontTransformation) other; + return Objects.equals(this.font, that.font); + } + + @Override + public int hashCode() { + return Objects.hash(this.font); + } +} diff --git a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/transformation/inbuild/GradientTransformation.java b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/transformation/inbuild/GradientTransformation.java new file mode 100644 index 000000000..bb16cf96e --- /dev/null +++ b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/transformation/inbuild/GradientTransformation.java @@ -0,0 +1,245 @@ +/* + * This file is part of adventure, licensed under the MIT License. + * + * Copyright (c) 2017-2021 KyoriPowered + * + * 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 net.kyori.adventure.text.minimessage.transformation.inbuild; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.PrimitiveIterator; +import java.util.stream.Stream; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.TextComponent; +import net.kyori.adventure.text.flattener.ComponentFlattener; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.format.TextColor; +import net.kyori.adventure.text.minimessage.parser.ParsingException; +import net.kyori.adventure.text.minimessage.parser.node.ElementNode; +import net.kyori.adventure.text.minimessage.parser.node.TagNode; +import net.kyori.adventure.text.minimessage.parser.node.TagPart; +import net.kyori.adventure.text.minimessage.parser.node.ValueNode; +import net.kyori.adventure.text.minimessage.transformation.Modifying; +import net.kyori.adventure.text.minimessage.transformation.Transformation; +import net.kyori.examination.ExaminableProperty; +import org.jetbrains.annotations.NotNull; + +/** + * A transformation that applies a colour gradient. + * + * @since 4.10.0 + */ +public final class GradientTransformation extends Transformation implements Modifying { + private int size = 0; + private int disableApplyingColorDepth = -1; + + private int index = 0; + private int colorIndex = 0; + + private float factorStep = 0; + private final TextColor[] colors; + private float phase; + private final boolean negativePhase; + + /** + * Create a new gradient transformation from a tag. + * + * @param name the tag name + * @param args the tag arguments + * @return a new transformation + * @since 4.10.0 + */ + public static GradientTransformation create(final String name, final List args) { + float phase = 0; + final List textColors; + if (!args.isEmpty()) { + textColors = new ArrayList<>(); + for (int i = 0; i < args.size(); i++) { + final String arg = args.get(i).value(); + // last argument? maybe this is the phase? + if (i == args.size() - 1) { + try { + phase = Float.parseFloat(arg); + if (phase < -1f || phase > 1f) { + throw new ParsingException(String.format("Gradient phase is out of range (%s). Must be in the range [-1.0f, 1.0f] (inclusive).", phase), args); + } + break; + } catch (final NumberFormatException ignored) { + } + } + + final TextColor parsedColor; + if (arg.charAt(0) == '#') { + parsedColor = TextColor.fromHexString(arg); + } else { + parsedColor = NamedTextColor.NAMES.value(arg.toLowerCase(Locale.ROOT)); + } + if (parsedColor == null) { + throw new ParsingException(String.format("Unable to parse a color from '%s'. Please use named colours or hex (#RRGGBB) colors.", arg), args); + } + textColors.add(parsedColor); + } + + if (textColors.size() < 2) { + throw new ParsingException("Invalid gradient, not enough colors. Gradients must have at least two colors.", args); + } + } else { + textColors = Collections.emptyList(); + } + + return new GradientTransformation(phase, textColors); + } + + private GradientTransformation(final float phase, final List colors) { + if (phase < 0) { + this.negativePhase = true; + this.phase = 1 + phase; + Collections.reverse(colors); + } else { + this.negativePhase = false; + this.phase = phase; + } + + if (colors.isEmpty()) { + this.colors = new TextColor[]{TextColor.color(0xffffff), TextColor.color(0x000000)}; + } else { + this.colors = colors.toArray(new TextColor[0]); + } + } + + @Override + public void visit(final ElementNode curr) { + if (curr instanceof ValueNode) { + final String value = ((ValueNode) curr).value(); + this.size += value.codePointCount(0, value.length()); + } else if (curr instanceof TagNode) { + final TagNode tag = (TagNode) curr; + if (tag.transformation() instanceof PlaceholderTransformation) { + // PlaceholderTransformation.apply() returns the value of the component placeholder + ComponentFlattener.textOnly().flatten(tag.transformation().apply(), s -> this.size += s.codePointCount(0, s.length())); + } + } + } + + @Override + public Component apply() { + // init + int sectorLength = this.size / (this.colors.length - 1); + if (sectorLength < 1) { + sectorLength = 1; + } + this.factorStep = 1.0f / (sectorLength + this.index); + this.phase = this.phase * sectorLength; + this.index = 0; + + return Component.empty(); + } + + @Override + public Component apply(final Component current, final int depth) { + if ((this.disableApplyingColorDepth != -1 && depth > this.disableApplyingColorDepth) || current.style().color() != null) { + if (this.disableApplyingColorDepth == -1) { + this.disableApplyingColorDepth = depth; + } + // This component has its own color applied, which overrides ours + // We still want to keep track of where we are though if this is text + if (current instanceof TextComponent) { + final String content = ((TextComponent) current).content(); + final int len = content.codePointCount(0, content.length()); + for (int i = 0; i < len; i++) { + // increment our color index + this.color(); + } + } + return current.children(Collections.emptyList()); + } + + if (current instanceof TextComponent && ((TextComponent) current).content().length() > 0) { + final TextComponent textComponent = (TextComponent) current; + final String content = textComponent.content(); + + final TextComponent.Builder parent = Component.text(); + + // apply + final int[] holder = new int[1]; + for (final PrimitiveIterator.OfInt it = content.codePoints().iterator(); it.hasNext();) { + holder[0] = it.nextInt(); + final Component comp = Component.text(new String(holder, 0, 1), this.color()); + parent.append(comp); + } + + return parent.build(); + } + + return Component.empty().mergeStyle(current); + } + + private TextColor color() { + // color switch needed? + if (this.factorStep * this.index > 1) { + this.colorIndex++; + this.index = 0; + } + + float factor = this.factorStep * (this.index++ + this.phase); + // loop around if needed + if (factor > 1) { + factor = 1 - (factor - 1); + } + + if (this.negativePhase && this.colors.length % 2 != 0) { + // flip the gradient segment for to allow for looping phase -1 through 1 + return TextColor.lerp(factor, this.colors[this.colorIndex + 1], this.colors[this.colorIndex]); + } else { + return TextColor.lerp(factor, this.colors[this.colorIndex], this.colors[this.colorIndex + 1]); + } + } + + @Override + public @NotNull Stream examinableProperties() { + return Stream.of( + ExaminableProperty.of("phase", this.phase), + ExaminableProperty.of("colors", this.colors) + ); + } + + @Override + public boolean equals(final Object other) { + if (this == other) return true; + if (other == null || this.getClass() != other.getClass()) return false; + final GradientTransformation that = (GradientTransformation) other; + return this.index == that.index + && this.colorIndex == that.colorIndex + && Float.compare(that.factorStep, this.factorStep) == 0 + && this.phase == that.phase && Arrays.equals(this.colors, that.colors); + } + + @Override + public int hashCode() { + int result = Objects.hash(this.index, this.colorIndex, this.factorStep, this.phase); + result = 31 * result + Arrays.hashCode(this.colors); + return result; + } +} diff --git a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/transformation/inbuild/HoverTransformation.java b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/transformation/inbuild/HoverTransformation.java new file mode 100644 index 000000000..acb761bbd --- /dev/null +++ b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/transformation/inbuild/HoverTransformation.java @@ -0,0 +1,153 @@ +/* + * This file is part of adventure, licensed under the MIT License. + * + * Copyright (c) 2017-2021 KyoriPowered + * + * 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 net.kyori.adventure.text.minimessage.transformation.inbuild; + +import java.util.List; +import java.util.Objects; +import java.util.UUID; +import java.util.stream.Stream; +import net.kyori.adventure.key.InvalidKeyException; +import net.kyori.adventure.key.Key; +import net.kyori.adventure.nbt.api.BinaryTagHolder; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.event.HoverEvent; +import net.kyori.adventure.text.minimessage.Context; +import net.kyori.adventure.text.minimessage.parser.ParsingException; +import net.kyori.adventure.text.minimessage.parser.node.TagPart; +import net.kyori.adventure.text.minimessage.transformation.Transformation; +import net.kyori.examination.ExaminableProperty; +import org.jetbrains.annotations.NotNull; + +/** + * A transformation that applies a {@link HoverEvent}. + * + * @since 4.10.0 + */ +public final class HoverTransformation extends Transformation { + private final HoverEvent.Action action; + private final Object value; + + /** + * Create a new hover transformation from a tag. + * + * @param ctx the active parse context + * @param name the tag name + * @param args the arguments provided + * @return a new transformation + * @throws ParsingException if an error occurs + * @since 4.10.0 + */ + @SuppressWarnings("unchecked") + public static HoverTransformation create(final Context ctx, final String name, final List args) { + if (args.size() < 2) { + throw new ParsingException("Doesn't know how to turn " + args + " into a hover event", args); + } + + final List newArgs = args.subList(1, args.size()); + + final HoverEvent.Action action = (HoverEvent.Action) HoverEvent.Action.NAMES.value(args.get(0).value()); + final Object value; + if (action == (Object) HoverEvent.Action.SHOW_TEXT) { + value = ctx.parse(newArgs.get(0).value()); + } else if (action == (Object) HoverEvent.Action.SHOW_ITEM) { + value = parseShowItem(newArgs); + } else if (action == (Object) HoverEvent.Action.SHOW_ENTITY) { + value = parseShowEntity(newArgs, ctx); + } else { + throw new ParsingException("Don't know how to turn '" + args + "' into a hover event", args); + } + + return new HoverTransformation(action, value); + } + + private static HoverEvent.@NotNull ShowItem parseShowItem(final @NotNull List args) { + try { + if (args.isEmpty()) { + throw new ParsingException("Show item hover needs at least item id!"); + } + final Key key = Key.key(args.get(0).value()); + final int count; + if (args.size() >= 2) { + count = Integer.parseInt(args.get(1).value()); + } else { + count = 1; + } + if (args.size() == 3) { + return HoverEvent.ShowItem.of(key, count, BinaryTagHolder.of(args.get(2).value())); + } + return HoverEvent.ShowItem.of(key, count); + } catch (final InvalidKeyException | NumberFormatException ex) { + throw new ParsingException("Exception parsing show_item hover", ex, args); + } + } + + private static HoverEvent.@NotNull ShowEntity parseShowEntity(final @NotNull List args, final Context context) { + try { + if (args.size() < 2) { + throw new ParsingException("Show entity hover needs at least type and uuid!"); + } + final Key key = Key.key(args.get(0).value()); + final UUID id = UUID.fromString(args.get(1).value()); + if (args.size() == 3) { + final Component name = context.parse(args.get(2).value()); + return HoverEvent.ShowEntity.of(key, id, name); + } + return HoverEvent.ShowEntity.of(key, id); + } catch (final IllegalArgumentException | InvalidKeyException ex) { + throw new ParsingException("Exception parsing show_entity hover", ex, args); + } + } + + private HoverTransformation(final HoverEvent.Action action, final Object value) { + this.action = action; + this.value = value; + } + + @Override + public Component apply() { + return Component.empty().hoverEvent(HoverEvent.hoverEvent(this.action, this.value)); + } + + @Override + public @NotNull Stream examinableProperties() { + return Stream.of( + ExaminableProperty.of("action", this.action), + ExaminableProperty.of("value", this.value) + ); + } + + @Override + public boolean equals(final Object other) { + if (this == other) return true; + if (other == null || this.getClass() != other.getClass()) return false; + final HoverTransformation that = (HoverTransformation) other; + return Objects.equals(this.action, that.action) + && Objects.equals(this.value, that.value); + } + + @Override + public int hashCode() { + return Objects.hash(this.action, this.value); + } +} diff --git a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/transformation/inbuild/InsertionTransformation.java b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/transformation/inbuild/InsertionTransformation.java new file mode 100644 index 000000000..65ee493cf --- /dev/null +++ b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/transformation/inbuild/InsertionTransformation.java @@ -0,0 +1,86 @@ +/* + * This file is part of adventure, licensed under the MIT License. + * + * Copyright (c) 2017-2021 KyoriPowered + * + * 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 net.kyori.adventure.text.minimessage.transformation.inbuild; + +import java.util.List; +import java.util.Objects; +import java.util.stream.Stream; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.minimessage.parser.ParsingException; +import net.kyori.adventure.text.minimessage.parser.node.TagPart; +import net.kyori.adventure.text.minimessage.transformation.Transformation; +import net.kyori.examination.ExaminableProperty; +import org.jetbrains.annotations.NotNull; + +/** + * A transformation that applies an insertion (shift-click) event. + * + * @since 4.10.0 + */ +public final class InsertionTransformation extends Transformation { + private final String insertion; + + /** + * Create a new insertion transformation from a tag. + * + * @param name the tag name + * @param args the tag arguments + * @return a new transformation + * @since 4.10.0 + */ + public static InsertionTransformation create(final String name, final List args) { + if (args.size() != 1) { + throw new ParsingException("Doesn't know how to turn token with name '" + name + "' and arguments " + args + " into a insertion component", args); + } + + return new InsertionTransformation(args.get(0).value()); + } + + private InsertionTransformation(final String insertion) { + this.insertion = insertion; + } + + @Override + public Component apply() { + return Component.empty().insertion(this.insertion); + } + + @Override + public @NotNull Stream examinableProperties() { + return Stream.of(ExaminableProperty.of("insertion", this.insertion)); + } + + @Override + public boolean equals(final Object other) { + if (this == other) return true; + if (other == null || this.getClass() != other.getClass()) return false; + final InsertionTransformation that = (InsertionTransformation) other; + return Objects.equals(this.insertion, that.insertion); + } + + @Override + public int hashCode() { + return Objects.hash(this.insertion); + } +} diff --git a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/transformation/inbuild/KeybindTransformation.java b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/transformation/inbuild/KeybindTransformation.java new file mode 100644 index 000000000..e9776676b --- /dev/null +++ b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/transformation/inbuild/KeybindTransformation.java @@ -0,0 +1,86 @@ +/* + * This file is part of adventure, licensed under the MIT License. + * + * Copyright (c) 2017-2021 KyoriPowered + * + * 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 net.kyori.adventure.text.minimessage.transformation.inbuild; + +import java.util.List; +import java.util.Objects; +import java.util.stream.Stream; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.minimessage.parser.ParsingException; +import net.kyori.adventure.text.minimessage.parser.node.TagPart; +import net.kyori.adventure.text.minimessage.transformation.Inserting; +import net.kyori.adventure.text.minimessage.transformation.Transformation; +import net.kyori.examination.ExaminableProperty; +import org.jetbrains.annotations.NotNull; + +/** + * A transformation that inserts a key binding component. + * + * @since 4.10.0 + */ +public final class KeybindTransformation extends Transformation implements Inserting { + /** + * Create a new keybind transformation from a tag. + * + * @param name the tag name + * @param args the tag arguments + * @return a new transformation + * @since 4.10.0 + */ + public static KeybindTransformation create(final String name, final List args) { + if (args.size() != 1) { + throw new ParsingException("Doesn't know how to turn token with name '" + name + "' and arguments " + args + " into a keybind component", args); + } + return new KeybindTransformation(args.get(0).value()); + } + + private final String keybind; + + private KeybindTransformation(final String keybind) { + this.keybind = keybind; + } + + @Override + public Component apply() { + return Component.keybind(this.keybind); + } + + @Override + public @NotNull Stream examinableProperties() { + return Stream.of(ExaminableProperty.of("keybind", this.keybind)); + } + + @Override + public boolean equals(final Object other) { + if (this == other) return true; + if (other == null || this.getClass() != other.getClass()) return false; + final KeybindTransformation that = (KeybindTransformation) other; + return Objects.equals(this.keybind, that.keybind); + } + + @Override + public int hashCode() { + return Objects.hash(this.keybind); + } +} diff --git a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/transformation/inbuild/PlaceholderTransformation.java b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/transformation/inbuild/PlaceholderTransformation.java new file mode 100644 index 000000000..15baf2625 --- /dev/null +++ b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/transformation/inbuild/PlaceholderTransformation.java @@ -0,0 +1,82 @@ +/* + * This file is part of adventure, licensed under the MIT License. + * + * Copyright (c) 2017-2021 KyoriPowered + * + * 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 net.kyori.adventure.text.minimessage.transformation.inbuild; + +import java.util.Objects; +import java.util.stream.Stream; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.minimessage.placeholder.Placeholder; +import net.kyori.adventure.text.minimessage.transformation.Inserting; +import net.kyori.adventure.text.minimessage.transformation.Transformation; +import net.kyori.adventure.text.minimessage.transformation.TransformationFactory; +import net.kyori.examination.ExaminableProperty; +import org.jetbrains.annotations.NotNull; + +/** + * Inserts a formatted placeholder component into the result. + * + * @since 4.10.0 + */ +public final class PlaceholderTransformation extends Transformation implements Inserting { + private final Placeholder.@NotNull ComponentPlaceholder placeholder; + + private PlaceholderTransformation(final Placeholder.@NotNull ComponentPlaceholder placeholder) { + this.placeholder = placeholder; + } + + /** + * Create a new factory for placeholder transformations applying {@code placeholder}. + * + * @param placeholder the placeholder to apply + * @since 4.10.0 + */ + public static @NotNull TransformationFactory factory(final Placeholder.@NotNull ComponentPlaceholder placeholder) { + final PlaceholderTransformation instance = new PlaceholderTransformation(placeholder); + return (ctx, name, args) -> instance; + } + + @Override + public Component apply() { + return this.placeholder.value(); + } + + @Override + public @NotNull Stream examinableProperties() { + return Stream.of(ExaminableProperty.of("placeholder", this.placeholder)); + } + + @Override + public boolean equals(final Object other) { + if (this == other) return true; + if (other == null || this.getClass() != other.getClass()) return false; + final PlaceholderTransformation that = (PlaceholderTransformation) other; + return Objects.equals(this.placeholder, that.placeholder); + } + + @Override + public int hashCode() { + return Objects.hash(this.placeholder); + } + +} diff --git a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/transformation/inbuild/RainbowTransformation.java b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/transformation/inbuild/RainbowTransformation.java new file mode 100644 index 000000000..e0c076a1b --- /dev/null +++ b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/transformation/inbuild/RainbowTransformation.java @@ -0,0 +1,196 @@ +/* + * This file is part of adventure, licensed under the MIT License. + * + * Copyright (c) 2017-2021 KyoriPowered + * + * 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 net.kyori.adventure.text.minimessage.transformation.inbuild; + +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.PrimitiveIterator; +import java.util.stream.Stream; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.TextComponent; +import net.kyori.adventure.text.flattener.ComponentFlattener; +import net.kyori.adventure.text.format.TextColor; +import net.kyori.adventure.text.minimessage.Tokens; +import net.kyori.adventure.text.minimessage.parser.ParsingException; +import net.kyori.adventure.text.minimessage.parser.node.ElementNode; +import net.kyori.adventure.text.minimessage.parser.node.TagNode; +import net.kyori.adventure.text.minimessage.parser.node.TagPart; +import net.kyori.adventure.text.minimessage.parser.node.ValueNode; +import net.kyori.adventure.text.minimessage.transformation.Modifying; +import net.kyori.adventure.text.minimessage.transformation.Transformation; +import net.kyori.examination.ExaminableProperty; +import org.jetbrains.annotations.NotNull; + +/** + * Applies rainbow color to a component. + * + * @since 4.10.0 + */ +public final class RainbowTransformation extends Transformation implements Modifying { + private int size; + private int disableApplyingColorDepth = -1; + + private int colorIndex = 0; + + private float center = 128; + private float width = 127; + private double frequency = 1; + private final boolean reversed; + + private final int phase; + + /** + * Create a new rainbow transformation from a tag. + * + * @param name the tag name + * @param args the tag arguments + * @return a new transformation + * @since 4.10.0 + */ + public static RainbowTransformation create(final String name, final List args) { + boolean reversed = false; + int phase = 0; + + if (args.size() == 1) { + String value = args.get(0).value(); + if (args.get(0).value().startsWith(Tokens.REVERSE)) { + reversed = true; + value = value.replaceFirst(Tokens.REVERSE, ""); + } + if (value.length() > 0) { + try { + phase = Integer.parseInt(value); + } catch (final NumberFormatException ex) { + throw new ParsingException("Expected phase, got " + args.get(0), args); + } + } + } + + return new RainbowTransformation(reversed, phase); + } + + private RainbowTransformation(final boolean reversed, final int phase) { + this.reversed = reversed; + this.phase = phase; + } + + @Override + public void visit(final ElementNode curr) { + if (curr instanceof ValueNode) { + final String value = ((ValueNode) curr).value(); + this.size += value.codePointCount(0, value.length()); + } else if (curr instanceof TagNode) { + final TagNode tag = (TagNode) curr; + if (tag.transformation() instanceof PlaceholderTransformation) { + // PlaceholderTransformation.apply() returns the value of the component placeholder + ComponentFlattener.textOnly().flatten(tag.transformation().apply(), s -> this.size += s.codePointCount(0, s.length())); + } + } + } + + @Override + public Component apply() { + // init + this.center = 128; + this.width = 127; + this.frequency = Math.PI * 2 / this.size; + + return Component.empty(); + } + + @Override + public Component apply(final Component current, final int depth) { + if ((this.disableApplyingColorDepth != -1 && depth > this.disableApplyingColorDepth) || current.style().color() != null) { + if (this.disableApplyingColorDepth == -1) { + this.disableApplyingColorDepth = depth; + } + // This component has its own color applied, which overrides ours + // We still want to keep track of where we are though if this is text + if (current instanceof TextComponent) { + final String content = ((TextComponent) current).content(); + final int len = content.codePointCount(0, content.length()); + for (int i = 0; i < len; i++) { + // increment our color index + this.color(this.phase); + } + } + return current.children(Collections.emptyList()); + } + + this.disableApplyingColorDepth = -1; + if (current instanceof TextComponent && ((TextComponent) current).content().length() > 0) { + final TextComponent textComponent = (TextComponent) current; + final String content = textComponent.content(); + + final TextComponent.Builder parent = Component.text(); + + if (this.colorIndex == 0 && this.reversed) { + this.colorIndex = this.size - 1; + } + + // apply + final int[] holder = new int[1]; + for (final PrimitiveIterator.OfInt it = content.codePoints().iterator(); it.hasNext();) { + holder[0] = it.nextInt(); + final Component comp = Component.text(new String(holder, 0, 1), this.color(this.phase)); + parent.append(comp); + } + + return parent.build(); + } + + return Component.empty().mergeStyle(current); + } + + private TextColor color(final float phase) { + final int index = this.reversed ? this.colorIndex-- : this.colorIndex++; + final int red = (int) (Math.sin(this.frequency * index + 2 + phase) * this.width + this.center); + final int green = (int) (Math.sin(this.frequency * index + 0 + phase) * this.width + this.center); + final int blue = (int) (Math.sin(this.frequency * index + 4 + phase) * this.width + this.center); + return TextColor.color(red, green, blue); + } + + @Override + public @NotNull Stream examinableProperties() { + return Stream.of(ExaminableProperty.of("phase", this.phase)); + } + + @Override + public boolean equals(final Object other) { + if (this == other) return true; + if (other == null || this.getClass() != other.getClass()) return false; + final RainbowTransformation that = (RainbowTransformation) other; + return this.colorIndex == that.colorIndex + && Float.compare(that.center, this.center) == 0 + && Float.compare(that.width, this.width) == 0 + && Double.compare(that.frequency, this.frequency) == 0 + && this.phase == that.phase; + } + + @Override + public int hashCode() { + return Objects.hash(this.colorIndex, this.center, this.width, this.frequency, this.phase); + } +} diff --git a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/transformation/inbuild/TranslatableTransformation.java b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/transformation/inbuild/TranslatableTransformation.java new file mode 100644 index 000000000..339a82ff3 --- /dev/null +++ b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/transformation/inbuild/TranslatableTransformation.java @@ -0,0 +1,110 @@ +/* + * This file is part of adventure, licensed under the MIT License. + * + * Copyright (c) 2017-2021 KyoriPowered + * + * 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 net.kyori.adventure.text.minimessage.transformation.inbuild; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.stream.Stream; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.minimessage.Context; +import net.kyori.adventure.text.minimessage.parser.ParsingException; +import net.kyori.adventure.text.minimessage.parser.node.TagPart; +import net.kyori.adventure.text.minimessage.transformation.Inserting; +import net.kyori.adventure.text.minimessage.transformation.Transformation; +import net.kyori.examination.ExaminableProperty; +import org.jetbrains.annotations.NotNull; + +/** + * Insert a translation component into the result. + * + * @since 4.10.0 + */ +public final class TranslatableTransformation extends Transformation implements Inserting { + /** + * Create a new translatable transformation from a tag. + * + * @param name the tag name + * @param args the tag arguments + * @return a new transformation + * @since 4.10.0 + */ + public static TranslatableTransformation create(final Context ctx, final String name, final List args) { + if (args.isEmpty()) { + throw new ParsingException("Doesn't know how to turn " + args + " into a translatable component", args); + } + + final List with; + if (args.size() > 1) { + with = new ArrayList<>(); + for (final TagPart in : args.subList(1, args.size())) { + with.add(ctx.parse(in.value())); + } + } else { + with = Collections.emptyList(); + } + + return new TranslatableTransformation(args.get(0).value(), with); + } + + private final String key; + private final List inners; + + private TranslatableTransformation(final String key, final List with) { + this.key = key; + this.inners = with; + } + + @Override + public Component apply() { + if (this.inners.isEmpty()) { + return Component.translatable(this.key); + } else { + return Component.translatable(this.key, this.inners); + } + } + + @Override + public @NotNull Stream examinableProperties() { + return Stream.of( + ExaminableProperty.of("key", this.key), + ExaminableProperty.of("inners", this.inners) + ); + } + + @Override + public boolean equals(final Object other) { + if (this == other) return true; + if (other == null || this.getClass() != other.getClass()) return false; + final TranslatableTransformation that = (TranslatableTransformation) other; + return Objects.equals(this.key, that.key) + && Objects.equals(this.inners, that.inners); + } + + @Override + public int hashCode() { + return Objects.hash(this.key, this.inners); + } +} diff --git a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/transformation/inbuild/package-info.java b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/transformation/inbuild/package-info.java new file mode 100644 index 000000000..6d621f9b0 --- /dev/null +++ b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/transformation/inbuild/package-info.java @@ -0,0 +1,33 @@ +/* + * This file is part of adventure, licensed under the MIT License. + * + * Copyright (c) 2017-2021 KyoriPowered + * + * 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. + */ +/** + * Standard transformations. + * + *

These implementation classes should not be directly referenced. + * See {@link net.kyori.adventure.text.minimessage.transformation.TransformationType} instead for referencing these transformations.

+ */ +@ApiStatus.Internal +package net.kyori.adventure.text.minimessage.transformation.inbuild; + +import org.jetbrains.annotations.ApiStatus; diff --git a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/transformation/package-info.java b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/transformation/package-info.java new file mode 100644 index 000000000..8dea7976a --- /dev/null +++ b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/transformation/package-info.java @@ -0,0 +1,27 @@ +/* + * This file is part of adventure, licensed under the MIT License. + * + * Copyright (c) 2017-2021 KyoriPowered + * + * 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. + */ +/** + * Transformations. + */ +package net.kyori.adventure.text.minimessage.transformation; diff --git a/text-minimessage/src/test/java/net/kyori/adventure/text/minimessage/MiniMessageParserTest.java b/text-minimessage/src/test/java/net/kyori/adventure/text/minimessage/MiniMessageParserTest.java new file mode 100644 index 000000000..7088e7ba4 --- /dev/null +++ b/text-minimessage/src/test/java/net/kyori/adventure/text/minimessage/MiniMessageParserTest.java @@ -0,0 +1,1650 @@ +/* + * This file is part of adventure, licensed under the MIT License. + * + * Copyright (c) 2017-2021 KyoriPowered + * + * 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 net.kyori.adventure.text.minimessage; + +import java.util.UUID; +import net.kyori.adventure.key.Key; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.TextComponent; +import net.kyori.adventure.text.event.ClickEvent; +import net.kyori.adventure.text.event.HoverEvent; +import net.kyori.adventure.text.format.TextColor; +import net.kyori.adventure.text.minimessage.placeholder.Placeholder; +import net.kyori.adventure.text.minimessage.placeholder.PlaceholderResolver; +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; +import org.junit.jupiter.api.Test; + +import static net.kyori.adventure.key.Key.key; +import static net.kyori.adventure.text.Component.empty; +import static net.kyori.adventure.text.Component.keybind; +import static net.kyori.adventure.text.Component.text; +import static net.kyori.adventure.text.Component.translatable; +import static net.kyori.adventure.text.event.ClickEvent.openUrl; +import static net.kyori.adventure.text.event.ClickEvent.runCommand; +import static net.kyori.adventure.text.event.HoverEvent.showText; +import static net.kyori.adventure.text.format.NamedTextColor.BLACK; +import static net.kyori.adventure.text.format.NamedTextColor.BLUE; +import static net.kyori.adventure.text.format.NamedTextColor.DARK_GRAY; +import static net.kyori.adventure.text.format.NamedTextColor.GOLD; +import static net.kyori.adventure.text.format.NamedTextColor.GRAY; +import static net.kyori.adventure.text.format.NamedTextColor.GREEN; +import static net.kyori.adventure.text.format.NamedTextColor.RED; +import static net.kyori.adventure.text.format.NamedTextColor.WHITE; +import static net.kyori.adventure.text.format.NamedTextColor.YELLOW; +import static net.kyori.adventure.text.format.Style.style; +import static net.kyori.adventure.text.format.TextColor.color; +import static net.kyori.adventure.text.format.TextDecoration.BOLD; +import static net.kyori.adventure.text.format.TextDecoration.ITALIC; +import static net.kyori.adventure.text.format.TextDecoration.UNDERLINED; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; + +public class MiniMessageParserTest extends TestBase { + + @Test + void test() { + final Component expected1 = empty().color(YELLOW) + .append(text("TEST")) + .append(text(" nested").color(GREEN)) + .append(text("Test")); + final Component expected2 = empty().color(YELLOW) + .append(text("TEST")) + .append(empty().color(GREEN) + .append(text(" nested")) + .append(text("Test").color(YELLOW)) + ); + + final String input1 = "TEST nestedTest"; + final String input2 = "TEST nestedTest"; + + this.assertParsedEquals(expected1, input1); + this.assertParsedEquals(expected2, input2); + } + + @Test + void testBritish() { + final String input1 = "This is english"; // no it's british + final String input2 = "This is english"; + final String input3 = "This is still english"; // British is superior english + final String input4 = "This is still english"; + final Component out1 = this.PARSER.parse(input1); + final Component out2 = this.PARSER.parse(input2); + final Component out3 = this.PARSER.parse(input3); + final Component out4 = this.PARSER.parse(input4); + + assertEquals(out1, out2); + assertEquals(out3, out4); + } + + @Test + void testBritishColour() { + final String input1 = "This is english"; // no it's british + final String input2 = "This is english"; + final Component out1 = this.PARSER.parse(input1); + final Component out2 = this.PARSER.parse(input2); + + assertEquals(out1, out2); + } + + @Test + void testNewColor() { + final Component expected1 = empty().color(YELLOW) + .append(text("TEST")) + .append(text(" nested").color(GREEN)) + .append(text("Test")); + final Component expected2 = empty().color(YELLOW) + .append(text("TEST")) + .append(empty().color(GREEN) + .append(text(" nested")) + .append(text("Test").color(YELLOW)) + ); + + final String input1 = "TEST nestedTest"; + final String input2 = "TEST nestedTest"; + + this.assertParsedEquals(expected1, input1); + this.assertParsedEquals(expected2, input2); + } + + @Test + void testHexColor() { + final Component expected1 = empty().color(color(0xff00ff)) + .append(text("TEST")) + .append(text(" nested").color(color(0x00ff00))) + .append(text("Test")); + final Component expected2 = empty().color(color(0xff00ff)) + .append(text("TEST")) + .append(empty().color(color(0x00ff00)) + .append(text(" nested")) + .append(text("Test").color(color(0xff00ff))) + ); + + final String input1 = "TEST nestedTest"; + final String input2 = "TEST nestedTest"; + + this.assertParsedEquals(expected1, input1); + this.assertParsedEquals(expected2, input2); + } + + @Test + void testHexColorShort() { + final Component expected1 = empty().color(color(0xff00ff)) + .append(text("TEST")) + .append(text(" nested").color(color(0x00ff00))) + .append(text("Test")); + final Component expected2 = empty().color(color(0xff00ff)) + .append(text("TEST")) + .append(empty().color(color(0x00ff00)) + .append(text(" nested")) + .append(text("Test").color(color(0xff00ff))) + ); + + final String input1 = "<#ff00ff>TEST<#00ff00> nestedTest"; + final String input2 = "<#ff00ff>TEST<#00ff00> nested<#ff00ff>Test"; + + this.assertParsedEquals(expected1, input1); + this.assertParsedEquals(expected2, input2); + } + + @Test + void testHexColorC() { + final Component expected1 = empty().color(color(0xff00ff)) + .append(text("TEST")) + .append(text(" nested").color(color(0x00ff00))) + .append(text("Test")); + final Component expected2 = empty().color(color(0xff00ff)) + .append(text("TEST")) + .append(empty().color(color(0x00ff00)) + .append(text(" nested")) + .append(text("Test").color(color(0xff00ff))) + ); + + final String input1 = "TEST nestedTest"; + final String input2 = "TEST nestedTest"; + + this.assertParsedEquals(expected1, input1); + this.assertParsedEquals(expected2, input2); + } + + @Test + void testAllColorAliases() { + final Component expectedColorHex = text("AGGRESSIVE TEST").color(color(0xff00ff)); + final String inputColorHex = "AGGRESSIVE TEST"; + + final Component expectedColourHex = text("less aggressive test").color(color(0x00ffff)); + final String inputColourHex = "less aggressive test"; + + final Component expectedCHex = text("Mildly Aggressive Test").color(color(0x1234de)); + final String inputCHex = "Mildly Aggressive Test"; + + final Component expectedColorNamed = text("AGGRESSIVE TEST").color(color(RED)); + final String inputColorNamed = "AGGRESSIVE TEST"; + + final Component expectedColourNamed = text("less aggressive test").color(color(GREEN)); + final String inputColourNamed = "less aggressive test"; + + final Component expectedCNamed = text("Mildly Aggressive Test").color(color(BLUE)); + final String inputCNamed = "Mildly Aggressive Test"; + + this.assertParsedEquals(expectedColorHex, inputColorHex); + this.assertParsedEquals(expectedColourHex, inputColourHex); + this.assertParsedEquals(expectedCHex, inputCHex); + this.assertParsedEquals(expectedColorNamed, inputColorNamed); + this.assertParsedEquals(expectedColourNamed, inputColourNamed); + this.assertParsedEquals(expectedCNamed, inputCNamed); + } + + @Test + void testStripSimple() { + final String input = "TEST nestedTest"; + final String expected = "TEST nestedTest"; + assertEquals(expected, this.PARSER.stripTokens(input)); + } + + @Test + void testStripComplex() { + final String input = " random strangerclick here to FEEL it"; + final String expected = " random strangerclick here to FEEL it"; + assertEquals(expected, this.PARSER.stripTokens(input)); + } + + // https://github.com/KyoriPowered/adventure-text-minimessage/issues/169 + @Test + void testStripComplexInner() { + final String input = " random strangerclick here to FEEL it"; + final String expected = " random strangerclick here to FEEL it"; + assertEquals(expected, this.PARSER.stripTokens(input)); + } + + @Test + void testStripInner() { + final String input = "test:TEST\">TEST"; + final String expected = "TEST"; + assertEquals(expected, this.PARSER.stripTokens(input)); + } + + @Test + void testStripPlaceholders() { + final String input = "Hello, !"; + final String expected = "Hello, !"; + assertEquals(expected, this.PARSER.stripTokens(input, PlaceholderResolver.placeholders(Placeholder.placeholder("name", "you")))); + } + + @Test + void testEscapeSimple() { + final String input = "TEST nestedTest"; + final String expected = "\\TEST\\ nested\\Test"; + assertEquals(expected, this.PARSER.escapeTokens(input)); + } + + @Test + void testEscapeComplex() { + final String input = " random strangerclick here to FEEL it"; + final String expected = "\\ random \\stranger\\\\\\\\click here\\\\ to \\FEEL\\ it"; + assertEquals(expected, this.PARSER.escapeTokens(input)); + } + + @Test + void testEscapeInner() { + final String input = "test:TEST\">TEST"; + final String expected = "\\test:TEST\">TEST"; + assertEquals(expected, this.PARSER.escapeTokens(input)); + } + + // https://github.com/KyoriPowered/adventure-text-minimessage/issues/169 + @Test + void testEscapeComplexInner() { + final String input = " random strangerclick here to FEEL it"; + final String expected = "\\ random \\stranger\\\\\\\\click here \\\\ to \\FEEL\\ it"; + assertEquals(expected, this.PARSER.escapeTokens(input)); + } + + @Test + void testEscapePlaceholders() { + final String input = "Hello, !"; + final String expected = "Hello, \\\\!"; + assertEquals(expected, this.PARSER.escapeTokens(input, PlaceholderResolver.placeholders(Placeholder.placeholder("name", "you")))); + } + + @Test + void testUnescape() { + final String input = "TEST\\ nested\\Test"; + final String expected = "TEST nestedTest"; + final Component comp = this.PARSER.parse(input); + + assertEquals(expected, PlainTextComponentSerializer.plainText().serialize(comp)); + } + + @Test + void testNoUnescape() { + final String input = "TEST\\ \\\\< nested\\Test"; + final String expected = "TEST \\< nestedTest"; + final TextComponent comp = (TextComponent) this.PARSER.parse(input); + + assertEquals(expected, PlainTextComponentSerializer.plainText().serialize(comp)); + } + + @Test + void testEscapeParse() { + final String expected = "test"; + final String escaped = MiniMessage.miniMessage().escapeTokens(expected); + final Component comp = MiniMessage.miniMessage().parse(escaped); + + assertEquals(expected, PlainTextComponentSerializer.plainText().serialize(comp)); + } + + @Test + void checkPlaceholder() { + final String input = ""; + final Component expected = text("Hello!"); + final Component comp = this.PARSER.deserialize(input, PlaceholderResolver.resolving("test", "Hello!")); + + assertEquals(expected, comp); + } + + @Test + void testNiceMix() { + final String input = " random strangerclick here to FEEL it"; + final Component expected = empty().color(YELLOW) + .append(text("Hello!")) + .append(text(" random ")) + .append(text("stranger").decorate(BOLD)) + .append(text("click here").color(RED).decorate(UNDERLINED).clickEvent(runCommand("test command"))) + .append(empty().color(BLUE) + .append(text(" to ")) + .append(text("FEEL it").decorate(BOLD)) + ); + + this.assertParsedEquals(expected, input, "test", "Hello!"); + } + + @Test + void testColorSimple() { + final String input = "TEST"; + + this.assertParsedEquals(text("TEST").color(YELLOW), input); + } + + @Test + void testHover() { + final String input = "test\">TEST"; + final Component expected = text("TEST").hoverEvent(text("test").color(RED)); + + this.assertParsedEquals(expected, input); + } + + @Test + void testHover2() { + final String input = "test'>TEST"; + final Component expected = text("TEST").hoverEvent(text("test").color(RED)); + + this.assertParsedEquals(expected, input); + } + + @Test + void testHoverWithColon() { + final String input = "test:TEST\">TEST"; + final Component expected = text("TEST").hoverEvent(text("test:TEST").color(RED)); + + this.assertParsedEquals(expected, input); + } + + @Test + void testHoverMultiline() { + final String input = "test\ntest2'>TEST"; + final Component expected = text("TEST").hoverEvent(text("test\ntest2").color(RED)); + + this.assertParsedEquals(expected, input); + } + + // GH-101 + @Test + void testHoverWithInsertingComponent() { + final String input = ""; + final Component expected = translatable("item.minecraft.stick").hoverEvent(showText(text("Test"))).color(RED); + + this.assertParsedEquals(expected, input); + } + + @Test + void testClick() { + final String input = "TEST"; + final Component expected = text("TEST").clickEvent(runCommand("test")); + + this.assertParsedEquals(expected, input); + } + + @Test + void testClickExtendedCommand() { + final String input = "TEST"; + final Component expected = text("TEST").clickEvent(runCommand("/test command")); + + this.assertParsedEquals(expected, input); + } + + @Test + void testInvalidTag() { + final String input = ""; + final Component expected = text("").color(RED); + + this.assertParsedEquals(expected, input); + } + + @Test + void testInvalidTagComplex() { + final String input = " random strangerclick here to FEEL it"; + final Component expected = empty().color(YELLOW) + .append(text(" random ")) + .append(text("stranger").decorate(BOLD)) + .append(empty().clickEvent(runCommand("test command")) + .append(text("")) + .append(text("click here").color(RED).decorate(UNDERLINED)) + ).append(empty().color(BLUE) + .append(text(" to ")) + .append(text("FEEL it").decorate(BOLD)) + ); + + this.assertParsedEquals(expected, input); + } + + @Test + void testKeyBind() { + final String input = "Press to jump!"; + final Component expected = text("Press ") + .append(keybind("key.jump")) + .append(text(" to jump!")); + + this.assertParsedEquals(expected, input); + } + + @Test + void testKeyBindWithColor() { + final String input = "Press to jump!"; + final Component expected = text("Press ") + .append(empty().color(RED) + .append(keybind("key.jump")) + .append(text(" to jump!")) + ); + + this.assertParsedEquals(expected, input); + } + + @Test + void testTranslatable() { + final String input = "You should get a !"; + final Component expected = text("You should get a ") + .append(translatable("block.minecraft.diamond_block")) + .append(text("!")); + + this.assertParsedEquals(expected, input); + } + + @Test + void testTranslatableWith() { + final String input = "Test: 1':'Stone'>!"; + final Component expected = text("Test: ") + .append(translatable("commands.drop.success.single", text("1").color(RED), text("Stone").color(BLUE))) + .append(text("!")); + + this.assertParsedEquals(expected, input); + } + + @Test + void testTranslatableWithHover() { + final String input = "Test: dum\\'>1':'Stone'>!"; + final Component expected = text("Test: ") + .append(translatable( + "commands.drop.success.single", + text("1").color(RED).hoverEvent(showText(text("dum").color(RED))), + text("Stone").color(BLUE) + )) + .append(text("!")); + + this.assertParsedEquals(expected, input); + } + + @Test + void testKingAlter() { + final String input = "Ahoy mates!'>"; + final Component expected = text("Ahoy ") + .append(translatable("offset.-40", text("mates!").color(RED))); + + this.assertParsedEquals(expected, input); + } + + @Test + void testInsertion() { + final String input = "Click this to insert!"; + final Component expected = text("Click ") + .append(text("this").insertion("test")) + .append(text(" to insert!")); + + this.assertParsedEquals(expected, input); + } + + @Test + void testBackSpace() { + final String input = "\\!/ IMPORTANT \\!/"; + final Component expected = text("\\!/ IMPORTANT \\!/"); + + this.assertParsedEquals(expected, input); + } + + @Test + void testGH5() { + final String input = "» To download it from the internet, '>/!\\ install it from Options/ResourcePacks in your game\">CLICK HERE"; + final Component expected = empty().color(DARK_GRAY) + .append(text("»")) + .append(empty().color(GRAY) + .append(text(" To download it from the internet, ")) + .append(text("CLICK HERE").decorate(BOLD).color(GREEN).clickEvent(openUrl("https://www.google.com")).hoverEvent(showText(text("/!\\ install it from Options/ResourcePacks in your game").color(GREEN)))) + ); + + this.assertParsedEquals(expected, input, "pack_url", "https://www.google.com"); + } + + @Test + void testGH5Modified() { + final String input = "» To download it from the internet, '>/!\\ install it from \\'Options/ResourcePacks\\' in your game'>CLICK HERE"; + final Component expected = empty().color(DARK_GRAY) + .append(text("»")) + .append(empty().color(GRAY) + .append(text(" To download it from the internet, ")) + .append(text("CLICK HERE").decorate(BOLD).color(GREEN).hoverEvent(showText(text("/!\\ install it from 'Options/ResourcePacks' in your game").color(GREEN))).clickEvent(openUrl("https://www.google.com"))) + ); + + // should work + this.assertParsedEquals(expected, input, "pack_url", "https://www.google.com"); + } + + @Test + void testGH5Quoted() { + final String input = "» To download it from the internet, /!\\ install it from Options/ResourcePacks in your game\">CLICK HERE"; + final Component expected = empty().color(DARK_GRAY) + .append(text("»")) + .append(empty().color(GRAY) + .append(text(" To download it from the internet, ")) + .append(text("CLICK HERE").decorate(BOLD).color(GREEN).clickEvent(openUrl("https://www.google.com")).hoverEvent(showText(text("/!\\ install it from Options/ResourcePacks in your game").color(GREEN)))) + ); + + // should work + this.assertParsedEquals(expected, input); + + // shouldnt throw an error + this.PARSER.deserialize(input, PlaceholderResolver.resolving("url", "https://www.google.com")); + } + + @Test + void testReset() { + final String input = "Click this wooo to insert!"; + final Component expected = text("Click ") + .append(empty().color(YELLOW).insertion("test") + .append(text("this")) + .append(empty() + .append(text(" ", color(0xf3801f))) + .append(text("w", color(0x71f813))) + .append(text("o", color(0x03ca9c))) + .append(text("o", color(0x4135fe))) + .append(text("o", color(0xd507b1))) + ) + ).append(text(" to insert!")); + + this.assertParsedEquals(expected, input); + } + + @Test + void testRainbow() { + final String input = "Woo: ||||||||||||||||||||||||!"; + final Component expected = empty().color(YELLOW) + .append(text("Woo: ")) + .append(empty() + .append(text("|", color(0xf3801f))) + .append(text("|", color(0xe1a00d))) + .append(text("|", color(0xc9bf03))) + .append(text("|", color(0xacd901))) + .append(text("|", color(0x8bed08))) + .append(text("|", color(0x6afa16))) + .append(text("|", color(0x4bff2c))) + .append(text("|", color(0x2ffa48))) + .append(text("|", color(0x18ed68))) + .append(text("|", color(0x08d989))) + .append(text("|", color(0x01bfa9))) + .append(text("|", color(0x02a0c7))) + .append(text("|", color(0x0c80e0))) + .append(text("|", color(0x1e5ff2))) + .append(text("|", color(0x3640fc))) + .append(text("|", color(0x5326fe))) + .append(text("|", color(0x7412f7))) + .append(text("|", color(0x9505e9))) + .append(text("|", color(0xb401d3))) + .append(text("|", color(0xd005b7))) + .append(text("|", color(0xe71297))) + .append(text("|", color(0xf72676))) + .append(text("|", color(0xfe4056))) + .append(text("|", color(0xfd5f38))) + ) + .append(text("!")); + + this.assertParsedEquals(expected, input); + } + + @Test + void testRainbowBackwards() { + final String input = "Woo: ||||||||||||||||||||||||!"; + final Component expected = empty().color(YELLOW) + .append(text("Woo: ")) + .append(empty() + .append(text("|", color(0xfd5f38))) + .append(text("|", color(0xfe4056))) + .append(text("|", color(0xf72676))) + .append(text("|", color(0xe71297))) + .append(text("|", color(0xd005b7))) + .append(text("|", color(0xb401d3))) + .append(text("|", color(0x9505e9))) + .append(text("|", color(0x7412f7))) + .append(text("|", color(0x5326fe))) + .append(text("|", color(0x3640fc))) + .append(text("|", color(0x1e5ff2))) + .append(text("|", color(0x0c80e0))) + .append(text("|", color(0x02a0c7))) + .append(text("|", color(0x01bfa9))) + .append(text("|", color(0x08d989))) + .append(text("|", color(0x18ed68))) + .append(text("|", color(0x2ffa48))) + .append(text("|", color(0x4bff2c))) + .append(text("|", color(0x6afa16))) + .append(text("|", color(0x8bed08))) + .append(text("|", color(0xacd901))) + .append(text("|", color(0xc9bf03))) + .append(text("|", color(0xe1a00d))) + .append(text("|", color(0xf3801f))) + ) + .append(text("!")); + + this.assertParsedEquals(expected, input); + } + + @Test + void testRainbowPhase() { + final String input = "Woo: ||||||||||||||||||||||||!"; + final Component expected = empty().color(YELLOW) + .append(text("Woo: ")) + .append(empty() + .append(text("|", color(0x1ff35c))) + .append(text("|", color(0x0de17d))) + .append(text("|", color(0x03c99e))) + .append(text("|", color(0x01acbd))) + .append(text("|", color(0x088bd7))) + .append(text("|", color(0x166aec))) + .append(text("|", color(0x2c4bf9))) + .append(text("|", color(0x482ffe))) + .append(text("|", color(0x6818fb))) + .append(text("|", color(0x8908ef))) + .append(text("|", color(0xa901db))) + .append(text("|", color(0xc702c1))) + .append(text("|", color(0xe00ca3))) + .append(text("|", color(0xf21e82))) + .append(text("|", color(0xfc3661))) + .append(text("|", color(0xfe5342))) + .append(text("|", color(0xf77428))) + .append(text("|", color(0xe99513))) + .append(text("|", color(0xd3b406))) + .append(text("|", color(0xb7d001))) + .append(text("|", color(0x97e704))) + .append(text("|", color(0x76f710))) + .append(text("|", color(0x56fe24))) + .append(text("|", color(0x38fd3e))) + ) + .append(text("!")); + + this.assertParsedEquals(expected, input); + } + + @Test + void testRainbowPhaseBackwards() { + final String input = "Woo: ||||||||||||||||||||||||!"; + final Component expected = empty().color(YELLOW) + .append(text("Woo: ")) + .append(empty() + .append(text("|", color(0x38fd3e))) + .append(text("|", color(0x56fe24))) + .append(text("|", color(0x76f710))) + .append(text("|", color(0x97e704))) + .append(text("|", color(0xb7d001))) + .append(text("|", color(0xd3b406))) + .append(text("|", color(0xe99513))) + .append(text("|", color(0xf77428))) + .append(text("|", color(0xfe5342))) + .append(text("|", color(0xfc3661))) + .append(text("|", color(0xf21e82))) + .append(text("|", color(0xe00ca3))) + .append(text("|", color(0xc702c1))) + .append(text("|", color(0xa901db))) + .append(text("|", color(0x8908ef))) + .append(text("|", color(0x6818fb))) + .append(text("|", color(0x482ffe))) + .append(text("|", color(0x2c4bf9))) + .append(text("|", color(0x166aec))) + .append(text("|", color(0x088bd7))) + .append(text("|", color(0x01acbd))) + .append(text("|", color(0x03c99e))) + .append(text("|", color(0x0de17d))) + .append(text("|", color(0x1ff35c))) + ) + .append(text("!")); + + this.assertParsedEquals(expected, input); + } + + @Test + void testRainbowWithInsertion() { + final String input = "Woo: ||||||||||||||||||||||||!"; + final Component expected = empty().color(YELLOW) + .append(text("Woo: ")) + .append(empty().insertion("test") + .append(empty() + .append(text("|", color(0xf3801f))) + .append(text("|", color(0xe1a00d))) + .append(text("|", color(0xc9bf03))) + .append(text("|", color(0xacd901))) + .append(text("|", color(0x8bed08))) + .append(text("|", color(0x6afa16))) + .append(text("|", color(0x4bff2c))) + .append(text("|", color(0x2ffa48))) + .append(text("|", color(0x18ed68))) + .append(text("|", color(0x08d989))) + .append(text("|", color(0x01bfa9))) + .append(text("|", color(0x02a0c7))) + .append(text("|", color(0x0c80e0))) + .append(text("|", color(0x1e5ff2))) + .append(text("|", color(0x3640fc))) + .append(text("|", color(0x5326fe))) + .append(text("|", color(0x7412f7))) + .append(text("|", color(0x9505e9))) + .append(text("|", color(0xb401d3))) + .append(text("|", color(0xd005b7))) + .append(text("|", color(0xe71297))) + .append(text("|", color(0xf72676))) + .append(text("|", color(0xfe4056))) + .append(text("|", color(0xfd5f38))) + ).append(text("!")) + ); + + this.assertParsedEquals(expected, input); + } + + @Test + void testRainbowBackwardsWithInsertion() { + final String input = "Woo: ||||||||||||||||||||||||!"; + final Component expected = empty().color(YELLOW) + .append(text("Woo: ")) + .append(empty().insertion("test") + .append(empty() + .append(text("|", color(0xfd5f38))) + .append(text("|", color(0xfe4056))) + .append(text("|", color(0xf72676))) + .append(text("|", color(0xe71297))) + .append(text("|", color(0xd005b7))) + .append(text("|", color(0xb401d3))) + .append(text("|", color(0x9505e9))) + .append(text("|", color(0x7412f7))) + .append(text("|", color(0x5326fe))) + .append(text("|", color(0x3640fc))) + .append(text("|", color(0x1e5ff2))) + .append(text("|", color(0x0c80e0))) + .append(text("|", color(0x02a0c7))) + .append(text("|", color(0x01bfa9))) + .append(text("|", color(0x08d989))) + .append(text("|", color(0x18ed68))) + .append(text("|", color(0x2ffa48))) + .append(text("|", color(0x4bff2c))) + .append(text("|", color(0x6afa16))) + .append(text("|", color(0x8bed08))) + .append(text("|", color(0xacd901))) + .append(text("|", color(0xc9bf03))) + .append(text("|", color(0xe1a00d))) + .append(text("|", color(0xf3801f))) + ).append(text("!")) + ); + + this.assertParsedEquals(expected, input); + } + + @Test + void testGradient() { + final String input = "Woo: ||||||||||||||||||||||||!"; + final Component expected = empty().color(YELLOW) + .append(text("Woo: ")) + .append(empty() + .append(text("|", WHITE)) + .append(text("|", color(0xf4f4f4))) + .append(text("|", color(0xeaeaea))) + .append(text("|", color(0xdfdfdf))) + .append(text("|", color(0xd5d5d5))) + .append(text("|", color(0xcacaca))) + .append(text("|", color(0xbfbfbf))) + .append(text("|", color(0xb5b5b5))) + .append(text("|", GRAY)) + .append(text("|", color(0x9f9f9f))) + .append(text("|", color(0x959595))) + .append(text("|", color(0x8a8a8a))) + .append(text("|", color(0x808080))) + .append(text("|", color(0x757575))) + .append(text("|", color(0x6a6a6a))) + .append(text("|", color(0x606060))) + .append(text("|", DARK_GRAY)) + .append(text("|", color(0x4a4a4a))) + .append(text("|", color(0x404040))) + .append(text("|", color(0x353535))) + .append(text("|", color(0x2a2a2a))) + .append(text("|", color(0x202020))) + .append(text("|", color(0x151515))) + .append(text("|", color(0x0b0b0b))) + ).append(text("!")); + + this.assertParsedEquals(expected, input); + } + + @Test + void testGradientWithHover() { + final String input = "Woo: ||||||||||||||||||||||||!"; + final Component expected = empty().color(YELLOW) + .append(text("Woo: ")) + .append(empty().hoverEvent(showText(text("This is a test"))) + .append(empty() + .append(text("|", style(WHITE))) + .append(text("|", style(color(0xf4f4f4)))) + .append(text("|", style(color(0xeaeaea)))) + .append(text("|", style(color(0xdfdfdf)))) + .append(text("|", style(color(0xd5d5d5)))) + .append(text("|", style(color(0xcacaca)))) + .append(text("|", style(color(0xbfbfbf)))) + .append(text("|", style(color(0xb5b5b5)))) + .append(text("|", style(GRAY))) + .append(text("|", style(color(0x9f9f9f)))) + .append(text("|", style(color(0x959595)))) + .append(text("|", style(color(0x8a8a8a)))) + .append(text("|", style(color(0x808080)))) + .append(text("|", style(color(0x757575)))) + .append(text("|", style(color(0x6a6a6a)))) + .append(text("|", style(color(0x606060)))) + .append(text("|", style(DARK_GRAY))) + .append(text("|", style(color(0x4a4a4a)))) + .append(text("|", style(color(0x404040)))) + .append(text("|", style(color(0x353535)))) + .append(text("|", style(color(0x2a2a2a)))) + .append(text("|", style(color(0x202020)))) + .append(text("|", style(color(0x151515)))) + .append(text("|", style(color(0x0b0b0b)))) + ).append(text("!"))); + + this.assertParsedEquals(expected, input); + } + + @Test + void testGradient2() { + final String input = "Woo: ||||||||||||||||||||||||!"; + final Component expected = empty().color(YELLOW) + .append(text("Woo: ")) + .append(empty() + .append(text("|", color(0x5e4fa2))) + .append(text("|", color(0x64529f))) + .append(text("|", color(0x6b559c))) + .append(text("|", color(0x715899))) + .append(text("|", color(0x785b96))) + .append(text("|", color(0x7e5d93))) + .append(text("|", color(0x846090))) + .append(text("|", color(0x8b638d))) + .append(text("|", color(0x91668a))) + .append(text("|", color(0x976987))) + .append(text("|", color(0x9e6c84))) + .append(text("|", color(0xa46f81))) + .append(text("|", color(0xab727e))) + .append(text("|", color(0xb1747a))) + .append(text("|", color(0xb77777))) + .append(text("|", color(0xbe7a74))) + .append(text("|", color(0xc47d71))) + .append(text("|", color(0xca806e))) + .append(text("|", color(0xd1836b))) + .append(text("|", color(0xd78668))) + .append(text("|", color(0xde8965))) + .append(text("|", color(0xe48b62))) + .append(text("|", color(0xea8e5f))) + .append(text("|", color(0xf1915c))) + ) + .append(text("!")); + + this.assertParsedEquals(expected, input); + } + + @Test + void testGradient3() { + final String input = "Woo: ||||||||||||||||||||||||!"; + final Component expected = empty().color(YELLOW) + .append(text("Woo: ")) + .append(empty() + .append(text("|", GREEN)) + .append(text("|", color(0x55f85c))) + .append(text("|", color(0x55f163))) + .append(text("|", color(0x55ea6a))) + .append(text("|", color(0x55e371))) + .append(text("|", color(0x55dc78))) + .append(text("|", color(0x55d580))) + .append(text("|", color(0x55cd87))) + .append(text("|", color(0x55c68e))) + .append(text("|", color(0x55bf95))) + .append(text("|", color(0x55b89c))) + .append(text("|", color(0x55b1a3))) + .append(text("|", color(0x55aaaa))) + .append(text("|", color(0x55a3b1))) + .append(text("|", color(0x559cb8))) + .append(text("|", color(0x5595bf))) + .append(text("|", color(0x558ec6))) + .append(text("|", color(0x5587cd))) + .append(text("|", color(0x5580d5))) + .append(text("|", color(0x5578dc))) + .append(text("|", color(0x5571e3))) + .append(text("|", color(0x556aea))) + .append(text("|", color(0x5563f1))) + .append(text("|", color(0x555cf8))) + ).append(text("!")); + + this.assertParsedEquals(expected, input); + } + + @Test + void testGradientMultiColor() { + final String input = "Woo: ||||||||||||||||||||||||||||||||||||||||||||||||||||||!"; + final Component expected = empty().color(YELLOW) + .append(text("Woo: ")) + .append(empty() + .append(text("|", RED)) + .append(text("|", color(0xf25562))) + .append(text("|", color(0xe5556f))) + .append(text("|", color(0xd8557c))) + .append(text("|", color(0xcb5589))) + .append(text("|", color(0xbe5596))) + .append(text("|", color(0xb155a3))) + .append(text("|", color(0xa355b1))) + .append(text("|", color(0x9655be))) + .append(text("|", color(0x8955cb))) + .append(text("|", color(0x7c55d8))) + .append(text("|", color(0x6f55e5))) + .append(text("|", color(0x6255f2))) + .append(text("|", BLUE)) + .append(text("|", BLUE)) + .append(text("|", color(0x5562f2))) + .append(text("|", color(0x556fe5))) + .append(text("|", color(0x557cd8))) + .append(text("|", color(0x5589cb))) + .append(text("|", color(0x5596be))) + .append(text("|", color(0x55a3b1))) + .append(text("|", color(0x55b1a3))) + .append(text("|", color(0x55be96))) + .append(text("|", color(0x55cb89))) + .append(text("|", color(0x55d87c))) + .append(text("|", color(0x55e56f))) + .append(text("|", color(0x55f262))) + .append(text("|", GREEN)) + .append(text("|", GREEN)) + .append(text("|", color(0x62ff55))) + .append(text("|", color(0x6fff55))) + .append(text("|", color(0x7cff55))) + .append(text("|", color(0x89ff55))) + .append(text("|", color(0x96ff55))) + .append(text("|", color(0xa3ff55))) + .append(text("|", color(0xb1ff55))) + .append(text("|", color(0xbeff55))) + .append(text("|", color(0xcbff55))) + .append(text("|", color(0xd8ff55))) + .append(text("|", color(0xe5ff55))) + .append(text("|", color(0xf2ff55))) + .append(text("|", YELLOW)) + .append(text("|", YELLOW)) + .append(text("|", color(0xfff255))) + .append(text("|", color(0xffe555))) + .append(text("|", color(0xffd855))) + .append(text("|", color(0xffcb55))) + .append(text("|", color(0xffbe55))) + .append(text("|", color(0xffb155))) + .append(text("|", color(0xffa355))) + .append(text("|", color(0xff9655))) + .append(text("|", color(0xff8955))) + .append(text("|", color(0xff7c55))) + .append(text("|", color(0xff6f55))) + ).append(text("!")); + + this.assertParsedEquals(expected, input); + } + + @Test + void testGradientMultiColor2() { + final String input = "Woo: ||||||||||||||||||||||||||||||||||||||||||||||||||||||!"; + final Component expected = empty().color(YELLOW) + .append(text("Woo: ")) + .append(empty() + .append(text("|", BLACK)) + .append(text("|", color(0x90909))) + .append(text("|", color(0x131313))) + .append(text("|", color(0x1c1c1c))) + .append(text("|", color(0x262626))) + .append(text("|", color(0x2f2f2f))) + .append(text("|", color(0x393939))) + .append(text("|", color(0x424242))) + .append(text("|", color(0x4c4c4c))) + .append(text("|", DARK_GRAY)) + .append(text("|", color(0x5e5e5e))) + .append(text("|", color(0x686868))) + .append(text("|", color(0x717171))) + .append(text("|", color(0x7b7b7b))) + .append(text("|", color(0x848484))) + .append(text("|", color(0x8e8e8e))) + .append(text("|", color(0x979797))) + .append(text("|", color(0xa1a1a1))) + .append(text("|", GRAY)) + .append(text("|", color(0xb3b3b3))) + .append(text("|", color(0xbdbdbd))) + .append(text("|", color(0xc6c6c6))) + .append(text("|", color(0xd0d0d0))) + .append(text("|", color(0xd9d9d9))) + .append(text("|", color(0xe3e3e3))) + .append(text("|", color(0xececec))) + .append(text("|", color(0xf6f6f6))) + .append(text("|", WHITE)) + .append(text("|", WHITE)) + .append(text("|", color(0xf6f6f6))) + .append(text("|", color(0xececec))) + .append(text("|", color(0xe3e3e3))) + .append(text("|", color(0xd9d9d9))) + .append(text("|", color(0xd0d0d0))) + .append(text("|", color(0xc6c6c6))) + .append(text("|", color(0xbdbdbd))) + .append(text("|", color(0xb3b3b3))) + .append(text("|", GRAY)) + .append(text("|", color(0xa1a1a1))) + .append(text("|", color(0x979797))) + .append(text("|", color(0x8e8e8e))) + .append(text("|", color(0x848484))) + .append(text("|", color(0x7b7b7b))) + .append(text("|", color(0x717171))) + .append(text("|", color(0x686868))) + .append(text("|", color(0x5e5e5e))) + .append(text("|", DARK_GRAY)) + .append(text("|", color(0x4c4c4c))) + .append(text("|", color(0x424242))) + .append(text("|", color(0x393939))) + .append(text("|", color(0x2f2f2f))) + .append(text("|", color(0x262626))) + .append(text("|", color(0x1c1c1c))) + .append(text("|", color(0x131313))) + ).append(text("!")); + + this.assertParsedEquals(expected, input); + } + + @Test + void testGradientMultiColor2Phase() { + final String input = "Woo: ||||||||||||||||||||||||||||||||||||||||||||||||||||||!"; + final Component expected = empty().color(YELLOW) + .append(text("Woo: ")) + .append(empty() + .append(text("|", color(0xa6a6a6))) + .append(text("|", color(0x9c9c9c))) + .append(text("|", color(0x939393))) + .append(text("|", color(0x898989))) + .append(text("|", color(0x808080))) + .append(text("|", color(0x777777))) + .append(text("|", color(0x6d6d6d))) + .append(text("|", color(0x646464))) + .append(text("|", color(0x5a5a5a))) + .append(text("|", color(0x515151))) + .append(text("|", color(0x474747))) + .append(text("|", color(0x3e3e3e))) + .append(text("|", color(0x343434))) + .append(text("|", color(0x2b2b2b))) + .append(text("|", color(0x222222))) + .append(text("|", color(0x181818))) + .append(text("|", color(0xf0f0f))) + .append(text("|", color(0x50505))) + .append(text("|", color(0x40404))) + .append(text("|", color(0xe0e0e))) + .append(text("|", color(0x171717))) + .append(text("|", color(0x212121))) + .append(text("|", color(0x2a2a2a))) + .append(text("|", color(0x333333))) + .append(text("|", color(0x3d3d3d))) + .append(text("|", color(0x464646))) + .append(text("|", color(0x505050))) + .append(text("|", color(0x595959))) + .append(text("|", color(0x595959))) + .append(text("|", color(0x636363))) + .append(text("|", color(0x6c6c6c))) + .append(text("|", color(0x767676))) + .append(text("|", color(0x7f7f7f))) + .append(text("|", color(0x888888))) + .append(text("|", color(0x929292))) + .append(text("|", color(0x9b9b9b))) + .append(text("|", color(0xa5a5a5))) + .append(text("|", color(0xaeaeae))) + .append(text("|", color(0xb8b8b8))) + .append(text("|", color(0xc1c1c1))) + .append(text("|", color(0xcbcbcb))) + .append(text("|", color(0xd4d4d4))) + .append(text("|", color(0xdddddd))) + .append(text("|", color(0xe7e7e7))) + .append(text("|", color(0xf0f0f0))) + .append(text("|", color(0xfafafa))) + .append(text("|", color(0xfbfbfb))) + .append(text("|", color(0xf1f1f1))) + .append(text("|", color(0xe8e8e8))) + .append(text("|", color(0xdedede))) + .append(text("|", color(0xd5d5d5))) + .append(text("|", color(0xcccccc))) + .append(text("|", color(0xc2c2c2))) + .append(text("|", color(0xb9b9b9))) + ).append(text("!")); + + this.assertParsedEquals(expected, input); + } + + @Test + void testGradientPhase() { + final String input = "Woo: ||||||||||||||||||||||||!"; + final Component expected = empty().color(YELLOW) + .append(text("Woo: ")) + .append(empty() + .append(text("|", color(0x5588cc))) + .append(text("|", color(0x5581d3))) + .append(text("|", color(0x557ada))) + .append(text("|", color(0x5573e1))) + .append(text("|", color(0x556ce8))) + .append(text("|", color(0x5565ef))) + .append(text("|", color(0x555ef7))) + .append(text("|", color(0x5556fe))) + .append(text("|", color(0x555bf9))) + .append(text("|", color(0x5562f2))) + .append(text("|", color(0x5569eb))) + .append(text("|", color(0x5570e4))) + .append(text("|", color(0x5577dd))) + .append(text("|", color(0x557ed6))) + .append(text("|", color(0x5585cf))) + .append(text("|", color(0x558cc8))) + .append(text("|", color(0x5593c1))) + .append(text("|", color(0x559aba))) + .append(text("|", color(0x55a2b3))) + .append(text("|", color(0x55a9ab))) + .append(text("|", color(0x55b0a4))) + .append(text("|", color(0x55b79d))) + .append(text("|", color(0x55be96))) + .append(text("|", color(0x55c58f))) + ).append(text("!")); + + this.assertParsedEquals(expected, input); + } + + // see #91 + @Test + void testGradientWithInnerTokens() { + final String input = "123456!"; + final Component expected = empty() + .append(text("1", GREEN)) + .append(text("2", color(0x55e371))) + .append(text("3", color(0x55c68e))) + .append(empty().decorate(BOLD) + .append(text("4", color(0x55aaaa))) + .append(text("5", color(0x558ec6))) + .append(text("6", color(0x5571e3))) + ) + .append(text("!")); + + this.assertParsedEquals(expected, input); + } + + @Test + void testGradientWithInnerGradientWithInnerToken() { + final String input = "123456789abc!"; + final Component expected = empty() + .append(text("1", GREEN)) + .append(text("2", color(0x55f163))) + .append(text("3", color(0x55e371))) + .append(empty() + .append(text("4", RED)) + .append(text("5", color(0xff7155))) + .append(text("6", color(0xff8e55))) + .append(empty().decorate(BOLD) + .append(text("7", color(0xffaa55))) + .append(text("8", color(0xffc655))) + .append(text("9", color(0xffe355))) + ) + ) + .append(empty() + .append(text("a", color(0x5580d5))) + .append(text("b", color(0x5571e3))) + .append(text("c", color(0x5563f1))) + ) + .append(text("!")); + + this.assertParsedEquals(expected, input); + } + + @Test + void testRainbowWithInnerClick() { + final String input = "Rainbow: GH"; + final Component expected = text("Rainbow: ") + .append(empty().clickEvent(openUrl("https://github.com")) + .append(text("G").color(color(0xf3801f))) + .append(text("H").color(color(0x0c80e0))) + ); + + this.assertParsedEquals(expected, input); + } + + @Test + void testRainbowBackwardsWithInnerClick() { + final String input = "Rainbow: GH"; + final Component expected = text("Rainbow: ") + .append(empty().clickEvent(openUrl("https://github.com")) + .append(text("G").color(color(0x0c80e0))) + .append(text("H").color(color(0xf3801f))) + ); + + this.assertParsedEquals(expected, input); + } + + @Test + void testFont() { + final String input = "Nothing Uniform Alt Uniform"; + final Component expected = text("Nothing ") + .append(empty().style(s -> s.font(key("uniform"))) + .append(text("Uniform ")) + .append(text("Alt ").style(s -> s.font(key("alt")))) + .append(text(" Uniform")) + ); + + this.assertParsedEquals(expected, input); + } + + @Test + void testCustomFont() { + final String input = "Default Custom font Another custom font Back to previous font"; + final Component expected = text("Default ") + .append(empty().style(s -> s.font(key("myfont", "best_font"))) + .append(text("Custom font ")) + .append(text("Another custom font ").style(s -> s.font(key("custom", "worst_font")))) + .append(text("Back to previous font")) + ); + + this.assertParsedEquals(expected, input); + } + + @Test + void testFontNoNamespace() { + final String input = "Nothing Uniform Alt Uniform"; + final Component expected = text("Nothing ") + .append(empty().style(s -> s.font(key("uniform"))) + .append(text("Uniform ")) + .append(text("Alt ").style(s -> s.font(key("alt")))) + .append(text(" Uniform")) + ); + + this.assertParsedEquals(expected, input); + } + + // GH-37 + @Test + void testPhil() { + final String input = "My Message"; + final Component expected = text("My Message").hoverEvent(showText(text("Message 1\nMessage 2"))).color(RED); + + this.assertParsedEquals(expected, input); + } + + @Test + void testNonBmpCharactersInGradient() { + assertFalse(Character.isBmpCodePoint("𐌰".codePointAt(0))); + + final String input = "Something 𐌰𐌱𐌲"; + final Component expected = text("Something ") + .append(empty() + .append(text("𐌰", BLUE)) + .append(text("𐌱", color(0x558ec6))) + .append(text("𐌲", color(0x55c68e))) + ); + + this.assertParsedEquals(expected, input); + } + + @Test + void testDoubleNewLine() { + final Component expected = text("Hello\n\nWorld").color(RED); + final String input = "Hello\n\nWorld"; + this.assertParsedEquals(expected, input); + } + + @Test + void testMismatchedTags() { + final Component expected = text("hello").color(GREEN); + final String input = "hello"; + this.assertParsedEquals(expected, input); + } + + @Test + void testShowItemHover() { + final Component expected = text("test").hoverEvent(HoverEvent.showItem(Key.key("minecraft", "stone"), 5)); + final String input = "test"; + final String input1 = "test"; + this.assertParsedEquals(expected, input); + this.assertParsedEquals(expected, input1); + } + + @Test + void testShowEntityHover() { + final UUID uuid = UUID.randomUUID(); + final String nameString = "Custom Name!"; + final Component name = this.PARSER.parse(nameString); + final Component expected = text("test").hoverEvent(HoverEvent.showEntity(Key.key("minecraft", "zombie"), uuid, name)); + final String input = String.format("test", uuid, nameString); + final String input1 = String.format("test", uuid, nameString); + this.assertParsedEquals(expected, input); + this.assertParsedEquals(expected, input1); + } + + @Test + void testQuoteEscapingInArguments() { + final Component expected = translatable("test", text("\"\"")); + final Component expected1 = translatable("test", text("''")); + final Component expected2 = translatable("test", text("''")); + final Component expected3 = translatable("test", text("\"\"")); + final String input = ""; + final String input1 = ""; + final String input2 = ""; + final String input3 = ""; + this.assertParsedEquals(expected, input); + this.assertParsedEquals(expected1, input1); + this.assertParsedEquals(expected2, input2); + this.assertParsedEquals(expected3, input3); + } + + @Test + void testPlaceholderOrder() { + final Component expected = empty().color(GRAY) + .append(text("ONE")) + .append(empty().color(RED) + .append(text("TWO")) + .append(text(" ")) + .append(text("THREE")) + .append(text(" ")) + .append(text("FOUR")) + ); + final String input = " "; + + this.assertParsedEquals(expected, input, "arg1", text("ONE"), "arg2", text("TWO"), "arg3", text("THREE"), "arg4", + text("FOUR")); + } + + @Test + void testPlaceholderOrder2() { + final Component expected = empty() + .append(text("ONE").color(GRAY)) + .append(text("TWO").color(RED)) + .append(text("THREE").color(BLUE)) + .append(text(" ")) + .append(text("FOUR").color(GREEN)); + final String input = " "; + + this.assertParsedEquals(expected, input, "arg1", text("ONE"), "arg2", text("TWO"), "arg3", text("THREE"), "arg4", + text("FOUR")); + } + + // GH-68, GH-93 + @Test + void testAngleBracketsShit() { + final Component expected = empty().color(GRAY) + .append(text("<")) + .append(empty().color(YELLOW) + .append(text("TEST")) + .append(text("> Woo << double <3").color(GRAY)) + ); + + final String input = "<TEST> Woo << double <3"; + + this.assertParsedEquals(expected, input); + } + + // GH-111 + @Test + void testNoSwallowSpace() { + final Component expected = empty().color(RED).hoverEvent(showText(text("Test"))) + .append(text(" ")) + .append(translatable("item.minecraft.stick")); + final String input = " "; + + this.assertParsedEquals(expected, input); + } + + // GH-125 + @Test + void testNoRepeatedTextAfterUnclosedRainbow() { + final Component expected = text() + .append(text('r', color(0xf3801f))) + .append(text('a', color(0xcdbb04))) + .append(text('i', color(0x96e805))) + .append(text('n', color(0x59fe22))) + .append(text('b', color(0x25f654))) + .append(text('o', color(0x06d490))) + .append(text('w', color(0x039ec9))) + .append(text("yellow", YELLOW)) + .build(); + final String input = "rainbowyellow"; + + this.assertParsedEquals(expected, input); + } + + @Test + void testRainbowOrGradientContinuesAfterColoredInner() { + final Component expectedRainbow = text() + .append(text('r', color(0xf3801f))) + .append(text('a', color(0xc9bf03))) + .append(text('i', color(0x8bed08))) + .append(text('n', color(0x4bff2c))) + .append(text("white", WHITE)) + .append(text() + .append(text('b', color(0xb401d3))) + .append(text('o', color(0xe71297))) + .append(text('w', color(0xfe4056)))) + .build(); + final String rainbowInput = "rainwhitebow"; + + this.assertParsedEquals(expectedRainbow, rainbowInput); + + final Component expectedGradient = text() + .append(text('g', WHITE)) + .append(text('r', color(0xebebeb))) + .append(text('a', color(0xd8d8d8))) + .append(text('d', color(0xc4c4c4))) + .append(text("green", GREEN)) + .append(text() + .append(text('i', color(0x4e4e4e))) + .append(text('e', color(0x3b3b3b))) + .append(text('n', color(0x272727))) + .append(text('t', color(0x141414)))) + .build(); + final String gradientInput = "gradgreenient"; + + this.assertParsedEquals(expectedGradient, gradientInput); + } + + // GH-134 + @Test + void testEscapeOutsideOfContext() { + final String input = "\\\""; + final Component expected = text("\\\""); + + this.assertParsedEquals(expected, input); + } + + @Test + void testEscapeInsideOfContext() { + final String input = "Test"; + final Component expected = text() + .content("Test") + .hoverEvent(text("Look at this '")) + .build(); + + this.assertParsedEquals(expected, input); + } + + @Test + void testEscapeAtEnd() { + final String input = "Please don't crash \\"; + final Component expected = text("Please don't crash \\"); + + this.assertParsedEquals(expected, input); + } + + @Test + void gh137() { + final String input = ""; + final String input2 = "a"; + final Component expected1 = text("a", GOLD); + final Component expected2 = text().append(text("a", GOLD), text("a", YELLOW)).build(); + final Component expected3 = text().append(text("a", GOLD), text("a", YELLOW), text("a", YELLOW)).build(); + final Component expected4 = text().append(text("a", GOLD), text("a", TextColor.color(0xffd52b)), text("a", YELLOW), text("a", YELLOW)).build(); + + this.assertParsedEquals(expected1, input, Placeholder.placeholder("dum", text("a"))); + this.assertParsedEquals(expected2, input, Placeholder.placeholder("dum", text("aa"))); + this.assertParsedEquals(expected3, input, Placeholder.placeholder("dum", text("aaa"))); + this.assertParsedEquals(expected4, input, Placeholder.placeholder("dum", text("aaaa"))); + this.assertParsedEquals(expected4, input2, Placeholder.placeholder("dum", text("aaa"))); + } + + @Test + void gh147() { + final String input = ""; + final Component expected1 = text().append(text("y", color(0xf3801f)), text("o", color(0x0c80e0))).build(); + this.assertParsedEquals(expected1, input, Placeholder.placeholder("msg", text("yo"))); + } + + @Test + void testSingleCharGradient() { + final String input1 = "A"; + final String input2 = "AB"; + + final Component expected1 = text("A", RED); + final Component expected2 = text().append(text("A", RED), text("B", BLUE)).build(); + + this.assertParsedEquals(expected1, input1); + this.assertParsedEquals(expected2, input2); + } + + @Test + void testCaseInsensitive() { + final String input1 = "this is an error message"; + final String input2 = "also red"; + + final Component expected1 = text() + .color(RED) + .append(text("this is ")) + .append(text("an error", style(BOLD))) + .append(text(" message")) + .build(); + final Component expected2 = text("also red", RED); + + this.assertParsedEquals(expected1, input1); + this.assertParsedEquals(expected2, input2); + } + + // https://github.com/KyoriPowered/adventure-text-minimessage/issues/165 + @Test + void testClosingTagAtRootLevel() { + final String input = "onetwo"; + + final Component expected = text("onetwo"); + + this.assertParsedEquals(expected, input); + } + + // https://github.com/KyoriPowered/adventure-text-minimessage/issues/146 + @Test + void testStringPlaceholderInCommand() { + final String input = "'>Click to run the word!"; + final Component expected = text("Click to run the word!", GOLD) + .clickEvent(ClickEvent.runCommand("word Adventure")); + this.assertParsedEquals(expected, input, "word", "Adventure"); + } + + @Test + void testInvalidStringPlaceholderInCommand() { + final String input = " '>Click to run the word!"; + final Component expected = text("Click to run the word!", GOLD) + .clickEvent(ClickEvent.runCommand("word ")); + this.assertParsedEquals(expected, input, "word", "Adventure"); + } + + // https://github.com/KyoriPowered/adventure-text-minimessage/issues/166 + @Test + void testEmptyTagPart() { + final String input = "text"; + final String input2 = "text"; + + final Component expected = text("text").hoverEvent(showText(empty())); + + this.assertParsedEquals(expected, input); + this.assertParsedEquals(expected, input2); + } + + @Test + void testInvalidClick() { + final String input = "best click event"; + + final Component expected = text("best click event"); + + this.assertParsedEquals(expected, input); + } + + // https://github.com/KyoriPowered/adventure-text-minimessage/issues/140 + @Test + void testStringPlaceholderInHover() { + final String input = "'>Hover to see the word!"; + + final Component expected = text("Hover to see the word!", GOLD) + .hoverEvent(text("Word: Adventure")); + + this.assertParsedEquals(expected, input, "word", "Adventure"); + } + + @Test + void testDisabledDecoration() { + final String input = "TestTest2Test3"; + final Component expected = text().decoration(ITALIC, false) + .append(text("Test")) + .append(text().decoration(BOLD, false) + .append(text("Test2")) + .append(text("Test3").decorate(BOLD)) + ).build(); + + this.assertParsedEquals(expected, input); + } + + @Test + void testDisabledDecorationShorthand() { + final String input = "TestTest2Test3"; + final Component expected = text().decoration(ITALIC, false) + .append(text("Test")) + .append(text().decoration(BOLD, false) + .append(text("Test2")) + .append(text("Test3").decorate(BOLD)) + ).build(); + + this.assertParsedEquals(expected, input); + } + + @Test + void testErrorOnShorthandAndLongHand() { + final String input = "Go decide on something, god dammit!"; + final Component expected = text("Go decide on something, god dammit!"); + this.assertParsedEquals(expected, input); + } + + @Test + void testDecorationShorthandClosing() { + final String input = "Hello! spooky not spooky"; + final Component expected = text().decoration(ITALIC, false) + .append(text("Hello! ")) + .append(text().decoration(ITALIC, true) + .append(text("spooky"))) + .append(text(" not spooky")) + .build(); + this.assertParsedEquals(expected, input); + } + + @Test + void testRepeatedResolvingOfStringPlaceholders() { + final String input = " makes a sound"; + + final Component expected = text("cat makes a sound", RED); + + assertParsedEquals(expected, input, "animal", "", "feline", "cat"); + } +} diff --git a/text-minimessage/src/test/java/net/kyori/adventure/text/minimessage/MiniMessageSerializerTest.java b/text-minimessage/src/test/java/net/kyori/adventure/text/minimessage/MiniMessageSerializerTest.java new file mode 100644 index 000000000..bee49799c --- /dev/null +++ b/text-minimessage/src/test/java/net/kyori/adventure/text/minimessage/MiniMessageSerializerTest.java @@ -0,0 +1,309 @@ +/* + * This file is part of adventure, licensed under the MIT License. + * + * Copyright (c) 2017-2021 KyoriPowered + * + * 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 net.kyori.adventure.text.minimessage; + +import java.util.UUID; +import net.kyori.adventure.key.Key; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.ComponentLike; +import net.kyori.adventure.text.TextComponent; +import net.kyori.adventure.text.TextComponent.Builder; +import net.kyori.adventure.text.event.ClickEvent; +import net.kyori.adventure.text.event.HoverEvent; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.format.Style; +import net.kyori.adventure.text.format.TextColor; +import net.kyori.adventure.text.format.TextDecoration; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.Test; + +import static net.kyori.adventure.text.Component.text; +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class MiniMessageSerializerTest extends TestBase { + + @Test + void testColor() { + final String expected = "This is a test"; + + final Builder builder = Component.text().content("This is a test").color(NamedTextColor.RED); + + this.test(builder, expected); + } + + @Test + void testColorClosing() { + final String expected = "This is a test"; + + final Builder builder = Component.text() + .append(Component.text("This is a ", NamedTextColor.RED)) + .append(Component.text("test")); + + this.test(builder, expected); + } + + @Test + void testNestedColor() { + final String expected = "This is ablue test"; + + final Builder builder = Component.text() + .append(Component.text("This is a", NamedTextColor.RED)) + .append(Component.text("blue ", NamedTextColor.BLUE)) + .append(Component.text("test", NamedTextColor.RED)); + + this.test(builder, expected); + } + + @Test + void testDecoration() { + // TODO this minimessage string is invalid, prolly need to rewrite the whole parser for it to be fixed... + final String expected = "This is underlined, this isn't"; + + final Builder builder = Component.text() + .append(Component.text("This is ").decoration(TextDecoration.UNDERLINED, true) + .append(Component.text("underlined").decoration(TextDecoration.BOLD, true))) + .append(Component.text(", this isn't")); + this.test(builder, expected); + } + + @Test + void testDecorationNegated() { + final String expected = "Not underlinednot boldunderlined not underlined"; + + final Builder builder = Component.text() + .append(Component.text("Not underlined").decoration(TextDecoration.UNDERLINED, false) + .append(Component.text("not bold").decoration(TextDecoration.BOLD, false) + .append(Component.text("underlined").decoration(TextDecoration.UNDERLINED, true)))) + .append(Component.text(" not underlined").decoration(TextDecoration.UNDERLINED, false)); + + this.test(builder, expected); + } + + @Test + void testHover() { + final String expected = "Some hover that ends here"; + + final Builder builder = Component.text() + .append(Component.text("Some hover").hoverEvent(HoverEvent.showText(Component.text("---")))) + .append(Component.text(" that ends here")); + + this.test(builder, expected); + } + + @Test + void testParentHover() { + final String expected = "----\">This is a child with hover"; + + final Builder builder = Component.text().hoverEvent(HoverEvent.showText(Component.text() + .content("---").color(NamedTextColor.RED) + .append(Component.text("-", NamedTextColor.BLUE, TextDecoration.BOLD)) + .build())) + .append(Component.text("This is a child with hover")); + + this.test(builder, expected); + } + + @Test + void testHoverWithNested() { + final String expected = "----\">Some hover that ends here"; + + final Builder builder = Component.text() + .append(Component.text("Some hover").hoverEvent( + HoverEvent.showText(Component.text("---").color(NamedTextColor.RED) + .append(Component.text("-", NamedTextColor.BLUE, TextDecoration.BOLD))))) + .append(Component.text(" that ends here")); + + this.test(builder, expected); + } + + @Test + void testClick() { + final String expected = "Some click that ends here"; + + final Builder builder = Component.text() + .append(Component.text("Some click").clickEvent(ClickEvent.runCommand("test"))) + .append(Component.text(" that ends here")); + + this.test(builder, expected); + } + + @Test + void testContinuedClick() { + final String expected = "Some click that doesn't end here"; + + final Builder builder = Component.text() + .append(Component.text("Some click").clickEvent(ClickEvent.runCommand("test")) + .append(Component.text(" that doesn't end here", NamedTextColor.RED))); + + this.test(builder, expected); + } + + @Test + void testKeyBind() { + final String expected = "Press to jump!"; + + final Builder builder = Component.text() + .content("Press ") + .append(Component.keybind("key.jump")) + .append(Component.text(" to jump!")); + + this.test(builder, expected); + } + + @Test + void testKeyBindWithColor() { + final String expected = "Press to jump!"; + + final Builder builder = Component.text() + .content("Press ") + .append(Component.keybind("key.jump", NamedTextColor.RED) + .append(Component.text(" to jump!"))); + + this.test(builder, expected); + } + + @Test + void testTranslatable() { + final String expected = "You should get a !"; + + final Builder builder = Component.text() + .content("You should get a ") + .append(Component.translatable("block.minecraft.diamond_block")) + .append(Component.text("!")); + + this.test(builder, expected); + } + + @Test + void testTranslatableWithArgs() { + final String expected = ":arg\\\" 1\":\"arg 2\">"; + + final Component translatable = Component.translatable() + .key("some_key") + .args(text(":arg\" 1", NamedTextColor.RED), text("arg 2", NamedTextColor.BLUE)) + .build(); + + this.test(translatable, expected); + } + + @Test + void testInsertion() { + final String expected = "Click this to insert!"; + + final Builder builder = Component.text() + .append(Component.text("Click ")) + .append(Component.text("this").insertion("test")) + .append(Component.text(" to insert!")); + + this.test(builder, expected); + } + + @Test + void testHexColor() { + final String expected = "This is a test"; + + final Builder builder = Component.text() + .append(Component.text("This is a ").color(TextColor.fromHexString("#ff0000"))) + .append(Component.text("test")); + + this.test(builder, expected); + } + + @Test + void testFont() { + final String expected = "This is a test"; + + final Builder builder = Component.text() + .append(Component.text().content("This is a ").font(Key.key("minecraft", "default"))) + .append(Component.text("test")); + + this.test(builder, expected); + } + + @Test + void testRainbow() { + final String expected = "test >> reeeeeeeee"; + + final Component parsed = MiniMessage.miniMessage().parse(expected); + + final String serialized = MiniMessage.miniMessage().serialize(parsed); + final Component reparsed = MiniMessage.miniMessage().parse(serialized); + + assertEquals(this.prettyPrint(parsed), this.prettyPrint(reparsed)); + } + + @Test + void testShowItemHover() { + final TextComponent.Builder input = text() + .content("test") + .hoverEvent(HoverEvent.showItem(Key.key("minecraft", "stone"), 5)); + final String expected = "test"; + this.test(input, expected); + } + + @Test + void testShowEntityHover() { + final UUID uuid = UUID.randomUUID(); + final String nameString = "Custom Name!"; + final Component name = MiniMessage.miniMessage().parse(nameString); + final TextComponent.Builder input = text() + .content("test") + .hoverEvent(HoverEvent.showEntity(Key.key("minecraft", "zombie"), uuid, name)); + final String expected = String.format("test", uuid, nameString); + this.test(input, expected); + } + + // https://github.com/KyoriPowered/adventure-text-minimessage/issues/172 + @Test + void testHoverWithExtraFollowing() { + final Component component = Component.text("START", NamedTextColor.AQUA) + .append(Component.text("HOVERED", NamedTextColor.BLUE) + .hoverEvent(HoverEvent.showText(Component.text("Text on hover")))) + .append(Component.text("END")); + final String expected = "STARTHOVEREDEND"; + + this.test(component, expected); + } + + @Test + void testNestedStyles() { + // These are mostly arbitrary, but I don't want to test every single combination + final ComponentLike component = Component.text() + .append(Component.text("b+i+u", Style.style(TextDecoration.BOLD, TextDecoration.ITALIC, TextDecoration.UNDERLINED))) + .append(Component.text("color+insert", Style.style(NamedTextColor.RED)).insertion("meow")) + .append(Component.text("st+font", Style.style(TextDecoration.STRIKETHROUGH).font(Key.key("uniform")))) + .append(Component.text("empty")); + final String expected = "b+i+u" + + "color+insert" + + "st+font" + + "empty"; + + this.test(component, expected); + } + + private void test(final @NotNull ComponentLike builder, final @NotNull String expected) { + final String string = MiniMessageSerializer.serialize(builder.asComponent()); + assertEquals(expected, string); + } +} diff --git a/text-minimessage/src/test/java/net/kyori/adventure/text/minimessage/MiniMessageTest.java b/text-minimessage/src/test/java/net/kyori/adventure/text/minimessage/MiniMessageTest.java new file mode 100644 index 000000000..06814da19 --- /dev/null +++ b/text-minimessage/src/test/java/net/kyori/adventure/text/minimessage/MiniMessageTest.java @@ -0,0 +1,488 @@ +/* + * This file is part of adventure, licensed under the MIT License. + * + * Copyright (c) 2017-2021 KyoriPowered + * + * 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 net.kyori.adventure.text.minimessage; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.function.Function; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.ComponentLike; +import net.kyori.adventure.text.minimessage.parser.ParsingException; +import net.kyori.adventure.text.minimessage.placeholder.Placeholder; +import net.kyori.adventure.text.minimessage.placeholder.PlaceholderResolver; +import net.kyori.adventure.text.minimessage.transformation.TransformationRegistry; +import net.kyori.adventure.text.minimessage.transformation.TransformationType; +import org.junit.jupiter.api.Test; + +import static net.kyori.adventure.text.Component.empty; +import static net.kyori.adventure.text.Component.text; +import static net.kyori.adventure.text.event.ClickEvent.suggestCommand; +import static net.kyori.adventure.text.event.HoverEvent.showText; +import static net.kyori.adventure.text.format.NamedTextColor.BLUE; +import static net.kyori.adventure.text.format.NamedTextColor.GOLD; +import static net.kyori.adventure.text.format.NamedTextColor.GRAY; +import static net.kyori.adventure.text.format.NamedTextColor.GREEN; +import static net.kyori.adventure.text.format.NamedTextColor.RED; +import static net.kyori.adventure.text.format.NamedTextColor.YELLOW; +import static net.kyori.adventure.text.format.Style.style; +import static net.kyori.adventure.text.format.TextColor.color; +import static net.kyori.adventure.text.format.TextDecoration.BOLD; +import static net.kyori.adventure.text.format.TextDecoration.UNDERLINED; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class MiniMessageTest extends TestBase { + + @Test + void testNormalBuilder() { + final Component expected = text("Test").color(RED); + final String input = "Test"; + final MiniMessage miniMessage = MiniMessage.builder().build(); + + this.assertParsedEquals(miniMessage, expected, input); + } + + @Test + void testNormal() { + final Component expected = text("Test").color(RED); + final String input = "Test"; + final MiniMessage miniMessage = MiniMessage.miniMessage(); + + this.assertParsedEquals(miniMessage, expected, input); + } + + @Test + void testNormalPlaceholders() { + final Component expected = text("TEST").color(RED); + final String input = ""; + final MiniMessage miniMessage = MiniMessage.miniMessage(); + + this.assertParsedEquals(miniMessage, expected, input, "test", "TEST"); + } + + @Test + void testObjectPlaceholders() { + final Component expected = empty().color(RED) + .append(text("ONE")) + .append(text("TWO", GREEN)) + .append(empty().color(BLUE) + .append(text("THREE")) + .append(text("FOUR")) + .append(text("FIVE", YELLOW)) + ); + final String input = "ONETHREE"; + final MiniMessage miniMessage = MiniMessage.miniMessage(); + this.assertParsedEquals(miniMessage, expected, input, + "two", text("TWO", GREEN), + "four", "FOUR", + "five", text("FIVE", YELLOW)); + } + + @Test + void testObjectPlaceholdersUnbalanced() { + assertThrows(IllegalArgumentException.class, () -> MiniMessage.miniMessage().deserialize("ONETHREE", + PlaceholderResolver.resolving( + "two", text("TWO", GREEN), + "four", "FOUR", + "five" + ) + )); + } + + @Test + void testPlaceholderSimple() { + final Component expected = text("TEST"); + final String input = ""; + final MiniMessage miniMessage = MiniMessage.miniMessage(); + + this.assertParsedEquals(miniMessage, expected, input, Placeholder.placeholder("test", "TEST")); + } + + @Test + void testPlaceholderComponent() { + final Component expected = text("TEST", RED); + final String string = ""; + final MiniMessage miniMessage = MiniMessage.miniMessage(); + + this.assertParsedEquals(miniMessage, expected, string, Placeholder.placeholder("test", text("TEST", RED))); + } + + @Test + void testPlaceholderComponentInheritedStyle() { + final Component expected = text("TEST", RED, UNDERLINED, BOLD); + final String input = ""; + final MiniMessage miniMessage = MiniMessage.miniMessage(); + + this.assertParsedEquals(miniMessage, expected, input, Placeholder.placeholder("test", text("TEST", RED, UNDERLINED))); + } + + @Test + void testPlaceholderComponentMixed() { + final Component expected = empty().color(GREEN).decorate(BOLD) + .append(text("TEST", style(RED, UNDERLINED))) + .append(text("Test2")); + final String input = ""; + final MiniMessage miniMessage = MiniMessage.miniMessage(); + + final Placeholder t1 = Placeholder.placeholder("test", text("TEST", style(RED, UNDERLINED))); + final Placeholder t2 = Placeholder.placeholder("test2", "Test2"); + + this.assertParsedEquals(miniMessage, expected, input, t1, t2); + } + + // GH-103 + @Test + void testPlaceholderInHover() { + final Component expected = text("This is a test message.") + .hoverEvent(showText(text("[Plugin]").color(color(0xff0000)))); + + final String input = "'>This is a test message."; + final MiniMessage miniMessage = MiniMessage.miniMessage(); + + this.assertParsedEquals(miniMessage, expected, input, Placeholder.placeholder("prefix", MiniMessage.miniMessage().parse("<#FF0000>[Plugin]"))); + } + + @Test + void testCustomRegistry() { + final Component expected = text("").append(text("TEST")); + final String input = ""; + final MiniMessage miniMessage = MiniMessage.builder().transformations(TransformationRegistry.empty()).build(); + + this.assertParsedEquals(miniMessage, expected, input, "test", "TEST"); + } + + @Test + void testCustomRegistryBuilder() { + final Component expected = empty().color(GREEN) + .append(text("")) + .append(text("TEST")); + final String input = ""; + final TransformationRegistry registry = TransformationRegistry.builder() + .clear() + .add(TransformationType.COLOR) + .build(); + final MiniMessage miniMessage = MiniMessage.builder().transformations(registry).build(); + + this.assertParsedEquals(miniMessage, expected, input, "test", "TEST"); + } + + @Test + void testPlaceholderResolver() { + final Component expected = text("TEST", RED).decorate(BOLD); + + final String input = ""; + + final Function resolver = name -> { + if (name.equalsIgnoreCase("test")) { + return text("TEST").color(RED); + } + return null; + }; + + final MiniMessage miniMessage = MiniMessage.builder().placeholderResolver(PlaceholderResolver.dynamic(resolver)).build(); + + this.assertParsedEquals(miniMessage, expected, input); + } + + @Test + void testFilteringPlaceholderResolver() { + final Component expected = empty() + .append(text("ONE", RED)) + .append(text("")) + .append(text("TWO", RED)); + + final String input = ""; + + final Function resolver = name -> text(name.toUpperCase()).color(RED); + + final MiniMessage miniMessage = MiniMessage.builder().placeholderResolver( + PlaceholderResolver.filtering(PlaceholderResolver.dynamic(resolver), name -> name.key().equals("filtered")) + ).build(); + + this.assertParsedEquals(miniMessage, expected, input); + } + + @Test + void testGroupingPlaceholderResolver() { + final Component expected = empty() + .append(text("ONE", RED)) + .append(text("")) + .append(text("TWO", GREEN)); + + final String input = ""; + + final Function resolver = name -> { + if (name.equalsIgnoreCase("one")) { + return text("ONE").color(RED); + } + return null; + }; + + final MiniMessage miniMessage = MiniMessage.builder().placeholderResolver( + PlaceholderResolver.combining( + PlaceholderResolver.dynamic(resolver), + PlaceholderResolver.placeholders(Placeholder.placeholder("two", text("TWO", GREEN))) + ) + ).build(); + + this.assertParsedEquals(miniMessage, expected, input); + } + + @Test + void testOrderOfPlaceholders() { + final Component expected = text("A") + .append(text("B")) + .append(text("C")); + final String input = "<_c>"; + final MiniMessage miniMessage = MiniMessage.miniMessage(); + + this.assertParsedEquals(miniMessage, expected, input, + "a", text("A"), + "b", text("B"), + "_c", text("C")); + } + + @Test + void testUnbalancedPlaceholders() { + final String expected = "Argument 1 in pairs must be a String or ComponentLike value, was java.lang.Integer"; + assertEquals(expected, assertThrows(IllegalArgumentException.class, () -> MiniMessage.miniMessage().deserialize("", PlaceholderResolver.resolving("a", 2))).getMessage()); + } + + @Test + void testNodesInPlaceholder() { + final Component expected = empty().color(RED) + .append(text("MiniDigger")) + .append(empty().color(GRAY) + .append(text(": ")) + .append(text("Test").color(RED)) + ); + final String input = ": "; + final MiniMessage miniMessage = MiniMessage.miniMessage(); + + this.assertParsedEquals(miniMessage, expected, input, Placeholder.placeholder("username", text("MiniDigger")), Placeholder.placeholder("message", text("Test"))); + } + + @Test + void testLazyPlaceholder() { + final Component expected = text("This is a ") + .append(text("TEST")); + final String input = "This is a "; + final MiniMessage miniMessage = MiniMessage.miniMessage(); + + this.assertParsedEquals(miniMessage, expected, input, Placeholder.placeholder("test", () -> text("TEST"))); + } + + @Test + void testNonStrict() { + final String input = "Example: /plot flag set coral-dry true"; + final Component expected = empty().color(GRAY) + .append(text("Example: ")) + .append(text("/plot flag set coral-dry true") + .color(GOLD) + .clickEvent(suggestCommand("/plot flag set coral-dry true")) + ); + + final MiniMessage miniMessage = MiniMessage.builder() + .strict(false) + .build(); + + this.assertParsedEquals(miniMessage, expected, input); + } + + @Test + void testNonStrictGH69() { + final Component expected = text("<3"); + final MiniMessage miniMessage = MiniMessage.builder() + .strict(false) + .build(); + + this.assertParsedEquals(miniMessage, expected, MiniMessage.miniMessage().escapeTokens("<3")); + } + + @Test + void testStrictException() { + final String input = "Example: /plot flag set coral-dry true"; + assertThrows(ParsingException.class, () -> MiniMessage.builder().strict(true).build().parse(input)); + } + + @Test + void testMissingCloseOfHover() { + final String input = "Hello'TEST'> : '>"; + assertThrows(ParsingException.class, () -> MiniMessage.builder().strict(true).build().parse(input)); + } + + @Test + void testNonEndingComponent() { + final String input = " assertEquals(strings, Collections.singletonList("Expected end sometimes after open tag + name, but got name = Token{type=NAME, value=\"red is already created! Try different name! \"} and inners = []"))).build().parse(input); + } + + @Test + void testIncompleteTag() { + final String input = "Click here to win a new car!"; + final Component expected = empty().color(RED) + .append(text("Click here to win a new ")) + .append(text("car!").decorate(BOLD)); + + this.assertParsedEquals(expected, input); + } + + @Test + void allClosedTagsStrict() { + final String input = "REDGREENREDBLUE"; + final Component expected = empty().color(RED) + .append(text("RED")) + .append(text("GREEN").color(GREEN)) + .append(text("RED")) + .append(text("BLUE").color(BLUE)); + + this.assertParsedEquals(MiniMessage.builder().strict(true).build(), expected, input); + } + + @Test + void unclosedTagStrict() { + final String input = "REDGREENREDBLUE"; + + final String errorMessage = "All tags must be explicitly closed while in strict mode. End of string found with open tags: red, blue\n" + + "\tREDGREENREDBLUE\n" + + "\t^~~~^ ^~~~~^"; + + final ParsingException thrown = assertThrows(ParsingException.class, () -> MiniMessage.builder().strict(true).build().parse(input)); + assertEquals(thrown.getMessage(), errorMessage); + } + + @Test + void implicitCloseStrict() { + final String input = "REDGREENNO COLORBLUE"; + + final String errorMessage = "Unclosed tag encountered; green is not closed, because red was closed first.\n" + + "\tREDGREENNO COLORBLUE\n" + + "\t^~~~^ ^~~~~~^ ^~~~~^"; + + final ParsingException thrown = assertThrows(ParsingException.class, () -> MiniMessage.builder().strict(true).build().parse(input)); + assertEquals(thrown.getMessage(), errorMessage); + } + + @Test + void implicitCloseNestedStrict() { + final String input = "REDGREENBLUEYELLOW"; + + final String errorMessage = "Unclosed tag encountered; yellow is not closed, because green was closed first.\n" + + "\tREDGREENBLUEYELLOW\n" + + "\t ^~~~~~^ ^~~~~~~^ ^~~~~~~^"; + + final ParsingException thrown = assertThrows(ParsingException.class, () -> MiniMessage.builder().strict(true).build().parse(input)); + assertEquals(thrown.getMessage(), errorMessage); + } + + @Test + void resetWhileStrict() { + final String input = "REDGREENNO COLORBLUE"; + + final String errorMessage = " tags are not allowed when strict mode is enabled\n" + + "\tREDGREENNO COLORBLUE\n" + + "\t ^~~~~~^"; + + final ParsingException thrown = assertThrows(ParsingException.class, () -> MiniMessage.builder().strict(true).build().parse(input)); + assertEquals(thrown.getMessage(), errorMessage); + } + + @Test + void debugModeSimple() { + final String input = " RED "; + + final StringBuilder sb = new StringBuilder(); + MiniMessage.builder().debug(sb).build().parse(input); + final List messages = Arrays.asList(sb.toString().split("\n")); + + assertTrue(messages.contains("Beginning parsing message RED ")); + assertTrue(messages.contains("Attempting to match node 'red' at column 0")); + assertTrue(messages.contains("Successfully matched node 'red' to transformation ColorTransformation")); + assertTrue(messages.contains("Text parsed into element tree:")); + assertTrue(messages.contains("Node {")); + assertTrue(messages.contains(" TagNode('red') {")); + assertTrue(messages.contains(" TextNode(' RED ')")); + assertTrue(messages.contains(" }")); + assertTrue(messages.contains("}")); + } + + @Test + void debugModeMoreComplex() { + final String input = " RED BLUE bad click "; + + final StringBuilder sb = new StringBuilder(); + MiniMessage.builder().debug(sb).build().parse(input); + final List messages = Arrays.asList(sb.toString().split("\n")); + + assertTrue(messages.contains("Beginning parsing message RED BLUE bad click ")); + assertTrue(messages.contains("Attempting to match node 'red' at column 0")); + assertTrue(messages.contains("Successfully matched node 'red' to transformation ColorTransformation")); + assertTrue(messages.contains("Attempting to match node 'blue' at column 10")); + assertTrue(messages.contains("Successfully matched node 'blue' to transformation ColorTransformation")); + assertTrue(messages.contains("Attempting to match node 'click' at column 22")); + assertTrue(messages.contains("Could not match node 'click' - Don't know how to turn [] into a click event")); + assertTrue(messages.contains("\t RED BLUE bad click ")); + assertTrue(messages.contains("\t ^~~~~~^")); + assertTrue(messages.contains("Text parsed into element tree:")); + assertTrue(messages.contains("Node {")); + assertTrue(messages.contains(" TagNode('red') {")); + assertTrue(messages.contains(" TextNode(' RED ')")); + assertTrue(messages.contains(" TagNode('blue') {")); + assertTrue(messages.contains(" TextNode(' BLUE bad click ')")); + assertTrue(messages.contains(" }")); + assertTrue(messages.contains(" }")); + assertTrue(messages.contains("}")); + } + + @Test + void debugModeMoreComplexNoError() { + final String input = " RED BLUE good click "; + + final StringBuilder sb = new StringBuilder(); + MiniMessage.builder().debug(sb).build().parse(input); + final List messages = Arrays.asList(sb.toString().split("\n")); + + assertTrue(messages.contains("Beginning parsing message RED BLUE good click ")); + assertTrue(messages.contains("Attempting to match node 'red' at column 0")); + assertTrue(messages.contains("Successfully matched node 'red' to transformation ColorTransformation")); + assertTrue(messages.contains("Attempting to match node 'blue' at column 10")); + assertTrue(messages.contains("Successfully matched node 'blue' to transformation ColorTransformation")); + assertTrue(messages.contains("Attempting to match node 'click' at column 22")); + assertTrue(messages.contains("Successfully matched node 'click' to transformation ClickTransformation")); + assertTrue(messages.contains("Text parsed into element tree:")); + assertTrue(messages.contains("Node {")); + assertTrue(messages.contains(" TagNode('red') {")); + assertTrue(messages.contains(" TextNode(' RED ')")); + assertTrue(messages.contains(" TagNode('blue') {")); + assertTrue(messages.contains(" TextNode(' BLUE ')")); + assertTrue(messages.contains(" TagNode('click', 'open_url', 'https://github.com') {")); + assertTrue(messages.contains(" TextNode(' good click ')")); + assertTrue(messages.contains(" }")); + assertTrue(messages.contains(" }")); + assertTrue(messages.contains(" }")); + assertTrue(messages.contains("}")); + } +} diff --git a/text-minimessage/src/test/java/net/kyori/adventure/text/minimessage/PlaceholderTest.java b/text-minimessage/src/test/java/net/kyori/adventure/text/minimessage/PlaceholderTest.java new file mode 100644 index 000000000..bc05462c2 --- /dev/null +++ b/text-minimessage/src/test/java/net/kyori/adventure/text/minimessage/PlaceholderTest.java @@ -0,0 +1,41 @@ +/* + * This file is part of adventure, licensed under the MIT License. + * + * Copyright (c) 2017-2021 KyoriPowered + * + * 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 net.kyori.adventure.text.minimessage; + +import net.kyori.adventure.text.minimessage.placeholder.Placeholder; +import org.junit.jupiter.api.Test; + +import static net.kyori.adventure.text.Component.text; +import static org.junit.jupiter.api.Assertions.assertThrows; + +public class PlaceholderTest { + + // https://github.com/KyoriPowered/adventure-text-minimessage/issues/190 + @Test + void testCaseOfPlaceholders() { + assertThrows(IllegalArgumentException.class, () -> Placeholder.placeholder("HI", "hi")); + assertThrows(IllegalArgumentException.class, () -> Placeholder.placeholder("HI", text("hi"))); + assertThrows(IllegalArgumentException.class, () -> Placeholder.placeholder("HI", () -> text("hi"))); + } +} diff --git a/text-minimessage/src/test/java/net/kyori/adventure/text/minimessage/TestBase.java b/text-minimessage/src/test/java/net/kyori/adventure/text/minimessage/TestBase.java new file mode 100644 index 000000000..4c20d1427 --- /dev/null +++ b/text-minimessage/src/test/java/net/kyori/adventure/text/minimessage/TestBase.java @@ -0,0 +1,61 @@ +/* + * This file is part of adventure, licensed under the MIT License. + * + * Copyright (c) 2017-2021 KyoriPowered + * + * 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 net.kyori.adventure.text.minimessage; + +import java.util.stream.Collectors; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.minimessage.placeholder.PlaceholderResolver; +import net.kyori.examination.string.MultiLineStringExaminer; +import org.jetbrains.annotations.NotNull; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class TestBase { + + final MiniMessage PARSER = MiniMessage.builder().debug(System.out).build(); + + void assertParsedEquals(final @NotNull Component expected, final @NotNull String input) { + this.assertParsedEquals(this.PARSER, expected, input); + } + + void assertParsedEquals(final @NotNull Component expected, final @NotNull String input, final @NotNull Object... args) { + this.assertParsedEquals(this.PARSER, expected, input, args); + } + + void assertParsedEquals(final MiniMessage miniMessage, final Component expected, final String input) { + final String expectedSerialized = this.prettyPrint(expected.compact()); + final String actual = this.prettyPrint(miniMessage.parse(input).compact()); + assertEquals(expectedSerialized, actual); + } + + void assertParsedEquals(final MiniMessage miniMessage, final Component expected, final String input, final @NotNull Object... args) { + final String expectedSerialized = this.prettyPrint(expected.compact()); + final String actual = this.prettyPrint(miniMessage.deserialize(input, PlaceholderResolver.resolving(args)).compact()); + assertEquals(expectedSerialized, actual); + } + + final String prettyPrint(final Component component) { + return component.examine(MultiLineStringExaminer.simpleEscaping()).collect(Collectors.joining("\n")); + } +}