From 84770c31a0809da53b0d0e4ff7fdf272bff5ed6e Mon Sep 17 00:00:00 2001 From: rtm516 Date: Wed, 5 Feb 2025 22:32:17 +0000 Subject: [PATCH] Initial work on bedrock-serializer --- settings.gradle.kts | 1 + text-serializer-bedrock/build.gradle.kts | 10 + .../bedrock/BedrockComponentSerializer.java | 270 +++++++++ .../BedrockComponentSerializerImpl.java | 573 ++++++++++++++++++ .../bedrock/CharacterAndFormat.java | 249 ++++++++ .../bedrock/CharacterAndFormatImpl.java | 132 ++++ .../bedrock/CharacterAndFormatSet.java | 87 +++ .../text/serializer/bedrock/LegacyFormat.java | 127 ++++ .../text/serializer/bedrock/Reset.java | 35 ++ .../text/serializer/bedrock/package-info.java | 27 + .../BedrockComponentSerializerTest.java | 406 +++++++++++++ ...LinkingBedrockComponentSerializerTest.java | 141 +++++ 12 files changed, 2058 insertions(+) create mode 100644 text-serializer-bedrock/build.gradle.kts create mode 100644 text-serializer-bedrock/src/main/java/net/kyori/adventure/text/serializer/bedrock/BedrockComponentSerializer.java create mode 100644 text-serializer-bedrock/src/main/java/net/kyori/adventure/text/serializer/bedrock/BedrockComponentSerializerImpl.java create mode 100644 text-serializer-bedrock/src/main/java/net/kyori/adventure/text/serializer/bedrock/CharacterAndFormat.java create mode 100644 text-serializer-bedrock/src/main/java/net/kyori/adventure/text/serializer/bedrock/CharacterAndFormatImpl.java create mode 100644 text-serializer-bedrock/src/main/java/net/kyori/adventure/text/serializer/bedrock/CharacterAndFormatSet.java create mode 100644 text-serializer-bedrock/src/main/java/net/kyori/adventure/text/serializer/bedrock/LegacyFormat.java create mode 100644 text-serializer-bedrock/src/main/java/net/kyori/adventure/text/serializer/bedrock/Reset.java create mode 100644 text-serializer-bedrock/src/main/java/net/kyori/adventure/text/serializer/bedrock/package-info.java create mode 100644 text-serializer-bedrock/src/test/java/net/kyori/adventure/text/serializer/bedrock/BedrockComponentSerializerTest.java create mode 100644 text-serializer-bedrock/src/test/java/net/kyori/adventure/text/serializer/bedrock/LinkingBedrockComponentSerializerTest.java diff --git a/settings.gradle.kts b/settings.gradle.kts index 385de35cc..7bca6838b 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -45,6 +45,7 @@ sequenceOf( "text-serializer-json", "text-serializer-json-legacy-impl", "text-serializer-legacy", + "text-serializer-bedrock", "text-serializer-plain", "text-serializer-ansi", ).forEach { diff --git a/text-serializer-bedrock/build.gradle.kts b/text-serializer-bedrock/build.gradle.kts new file mode 100644 index 000000000..37a9ac640 --- /dev/null +++ b/text-serializer-bedrock/build.gradle.kts @@ -0,0 +1,10 @@ +plugins { + id("adventure.common-conventions") +} + +dependencies { + api(projects.adventureApi) + annotationProcessor(projects.adventureAnnotationProcessors) +} + +applyJarMetadata("net.kyori.adventure.text.serializer.bedrock") diff --git a/text-serializer-bedrock/src/main/java/net/kyori/adventure/text/serializer/bedrock/BedrockComponentSerializer.java b/text-serializer-bedrock/src/main/java/net/kyori/adventure/text/serializer/bedrock/BedrockComponentSerializer.java new file mode 100644 index 000000000..b340e9cd0 --- /dev/null +++ b/text-serializer-bedrock/src/main/java/net/kyori/adventure/text/serializer/bedrock/BedrockComponentSerializer.java @@ -0,0 +1,270 @@ +/* + * This file is part of adventure, licensed under the MIT License. + * + * Copyright (c) 2017-2025 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.serializer.bedrock; + +import java.util.List; +import java.util.function.Consumer; +import java.util.regex.Pattern; +import net.kyori.adventure.builder.AbstractBuilder; +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.flattener.ComponentFlattener; +import net.kyori.adventure.text.format.Style; +import net.kyori.adventure.text.format.TextColor; +import net.kyori.adventure.text.serializer.ComponentSerializer; +import net.kyori.adventure.util.Buildable; +import net.kyori.adventure.util.PlatformAPI; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * A legacy component serializer. + * + *

Legacy does not support more complex features such as, but not limited + * to, {@link ClickEvent} and {@link HoverEvent}.

+ * + * @since 4.0.0 + */ +public interface BedrockComponentSerializer extends ComponentSerializer, Buildable { + /** + * Gets a component serializer for legacy-based serialization and deserialization. Note that this + * serializer works exactly like vanilla Minecraft and does not detect any links. If you want to + * detect and make URLs clickable, use {@link Builder#extractUrls()}. + * + *

The returned serializer uses the {@link #SECTION_CHAR section} character.

+ * + * @return a component serializer for legacy serialization and deserialization + * @since 4.0.0 + */ + static @NotNull BedrockComponentSerializer bedrock() { + return BedrockComponentSerializerImpl.INSTANCE; + } + + /** + * Converts a legacy character ({@code 0123456789abcdefklmnor}) to a legacy format, when possible. + * + * @param character the legacy character + * @return the legacy format + * @since 4.0.0 + */ + static @Nullable LegacyFormat parseChar(final char character) { + return BedrockComponentSerializerImpl.legacyFormat(character); + } + + /** + * Creates a new {@link BedrockComponentSerializer.Builder}. + * + * @return the builder + * @since 4.0.0 + */ + static @NotNull Builder builder() { + return new BedrockComponentSerializerImpl.BuilderImpl(); + } + + /** + * The legacy character used by Minecraft. ('§') + * + * @since 4.0.0 + */ + char SECTION_CHAR = '§'; + + /** + * The legacy character used to prefix hex colors. ('#') + * + * @since 4.0.0 + */ + char HEX_CHAR = TextColor.HEX_CHARACTER; + + /** + * Deserialize a component from a legacy {@link String}. + * + * @param input the input + * @return the component + */ + @Override + @NotNull TextComponent deserialize(final @NotNull String input); + + /** + * Serializes a component into a legacy {@link String}. + * + * @param component the component + * @return the string + */ + @Override + @NotNull String serialize(final @NotNull Component component); + + /** + * A builder for {@link BedrockComponentSerializer}. + * + * @since 4.0.0 + */ + interface Builder extends AbstractBuilder, Buildable.Builder { + /** + * Sets the legacy character used by the serializer. + * + * @param legacyCharacter the legacy character + * @return this builder + * @since 4.0.0 + */ + @NotNull Builder character(final char legacyCharacter); + + /** + * Sets the legacy hex character used by the serializer. + * + * @param legacyHexCharacter the legacy hex character. + * @return this builder + * @since 4.0.0 + */ + @NotNull Builder hexCharacter(final char legacyHexCharacter); + + /** + * Sets that the serializer should extract URLs into {@link ClickEvent}s + * when deserializing. + * + * @return this builder + * @since 4.0.0 + */ + @NotNull Builder extractUrls(); + + /** + * Sets that the serializer should extract URLs into {@link ClickEvent}s + * when deserializing. + * + * @param pattern the url pattern + * @return this builder + * @since 4.2.0 + */ + @NotNull Builder extractUrls(final @NotNull Pattern pattern); + + /** + * Sets that the serializer should extract URLs into {@link ClickEvent}s + * when deserializing. + * + * @param style the style to use for extracted links + * @return this builder + * @since 4.0.0 + */ + @NotNull Builder extractUrls(final @Nullable Style style); + + /** + * Sets that the serializer should extract URLs into {@link ClickEvent}s + * when deserializing. + * + * @param pattern the url pattern + * @param style the style to apply to indicate that text is a link + * @return this builder + * @since 4.2.0 + */ + @NotNull Builder extractUrls(final @NotNull Pattern pattern, final @Nullable Style style); + + /** + * Sets that the serializer should support hex colors. + * + *

Otherwise, hex colors are downsampled to the nearest named color.

+ * + * @return this builder + * @since 4.0.0 + */ + @NotNull Builder hexColors(); + + /** + * Sets that the serializer should use the '&x' repeated code format when serializing hex + * colors. Note that messages in this format can still be deserialized, even with this option + * disabled. + * + *

This is the format adopted by the BungeeCord (and by usage, Spigot) text API.

+ * + *

The format is difficult to manipulate and read, and its use is not recommended. Support + * is provided for it only to allow plugin developers to use this library alongside parts of + * the Spigot API which expect legacy strings in this format.

+ * + *

It is recommended to use only when absolutely necessary, and when no better alternatives + * are available.

+ * + * @return this builder + * @since 4.0.0 + */ + @NotNull Builder useUnusualXRepeatedCharacterHexFormat(); + + /** + * Use this component flattener to convert components into plain text. + * + *

By default, this serializer will use {@link ComponentFlattener#basic()}

+ * + * @param flattener the flattener to use + * @return this builder + * @since 4.7.0 + */ + @NotNull Builder flattener(final @NotNull ComponentFlattener flattener); + + /** + * Sets the formats to use. + * + * @param formats the formats + * @return this builder + * @since 4.14.0 + */ + @NotNull Builder formats(final @NotNull List formats); + + /** + * Builds the serializer. + * + * @return the built serializer + */ + @Override + @NotNull BedrockComponentSerializer build(); + } + + /** + * A {@link BedrockComponentSerializer} service provider. + * + * @since 4.8.0 + */ + @ApiStatus.Internal + @PlatformAPI + interface Provider { + /** + * Provides a {@link BedrockComponentSerializer}. + * + * @return a {@link BedrockComponentSerializer} + * @since 4.8.0 + */ + @ApiStatus.Internal + @PlatformAPI + @NotNull BedrockComponentSerializer bedrock(); + + /** + * Completes the building process of {@link Builder}. + * + * @return a {@link Consumer} + * @since 4.8.0 + */ + @ApiStatus.Internal + @PlatformAPI + @NotNull Consumer legacy(); + } +} diff --git a/text-serializer-bedrock/src/main/java/net/kyori/adventure/text/serializer/bedrock/BedrockComponentSerializerImpl.java b/text-serializer-bedrock/src/main/java/net/kyori/adventure/text/serializer/bedrock/BedrockComponentSerializerImpl.java new file mode 100644 index 000000000..e9fb53229 --- /dev/null +++ b/text-serializer-bedrock/src/main/java/net/kyori/adventure/text/serializer/bedrock/BedrockComponentSerializerImpl.java @@ -0,0 +1,573 @@ +/* + * This file is part of adventure, licensed under the MIT License. + * + * Copyright (c) 2017-2025 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.serializer.bedrock; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.EnumSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.function.Consumer; +import java.util.regex.Pattern; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.TextComponent; +import net.kyori.adventure.text.TextReplacementConfig; +import net.kyori.adventure.text.event.ClickEvent; +import net.kyori.adventure.text.flattener.ComponentFlattener; +import net.kyori.adventure.text.flattener.FlattenerListener; +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 net.kyori.adventure.text.format.TextFormat; +import net.kyori.adventure.util.Services; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import static java.util.Objects.requireNonNull; + +final class BedrockComponentSerializerImpl implements BedrockComponentSerializer { + static final Pattern DEFAULT_URL_PATTERN = Pattern.compile("(?:(https?)://)?([-\\w_.]+\\.\\w{2,})(/\\S*)?"); + static final Pattern URL_SCHEME_PATTERN = Pattern.compile("^[a-z][a-z0-9+\\-.]*:"); + private static final TextDecoration[] DECORATIONS = TextDecoration.values(); + private static final char LEGACY_BUNGEE_HEX_CHAR = 'x'; + + private static final Optional SERVICE = Services.service(Provider.class); + static final Consumer BUILDER = SERVICE + .map(Provider::legacy) + .orElseGet(() -> builder -> { + // NOOP + }); + + static final BedrockComponentSerializer INSTANCE = SERVICE + .map(Provider::bedrock) + .orElseGet(() -> new BedrockComponentSerializerImpl(SECTION_CHAR, HEX_CHAR, null, false, false, ComponentFlattener.basic(), CharacterAndFormatSet.DEFAULT)); + + private final char character; + private final char hexCharacter; + private final @Nullable TextReplacementConfig urlReplacementConfig; + private final boolean hexColours; + private final boolean useTerriblyStupidHexFormat; // (╯°□°)╯︵ ┻━┻ + private final ComponentFlattener flattener; + private final CharacterAndFormatSet formats; + + BedrockComponentSerializerImpl(final char character, final char hexCharacter, final @Nullable TextReplacementConfig urlReplacementConfig, final boolean hexColours, final boolean useTerriblyStupidHexFormat, final ComponentFlattener flattener, final CharacterAndFormatSet formats) { + this.character = character; + this.hexCharacter = hexCharacter; + this.urlReplacementConfig = urlReplacementConfig; + this.hexColours = hexColours; + this.useTerriblyStupidHexFormat = useTerriblyStupidHexFormat; + this.flattener = flattener; + this.formats = formats; + } + + private @Nullable FormatCodeType determineFormatType(final char legacy, final String input, final int pos) { + if (pos >= 14) { + // The BungeeCord RGB color format uses a repeating sequence of RGB values, each character formatted + // as their own color format string, and to make things interesting, all the colors are also valid + // Mojang colors. To differentiate this, we do a lookback check for &x (or equivalent) for its position + // in the string if it is indeed a BungeeCord-style RGB color. + final int expectedCharacterPosition = pos - 14; + final int expectedIndicatorPosition = pos - 13; + if (input.charAt(expectedCharacterPosition) == this.character && input.charAt(expectedIndicatorPosition) == LEGACY_BUNGEE_HEX_CHAR) { + return FormatCodeType.BUNGEECORD_UNUSUAL_HEX; + } + } + if (legacy == this.hexCharacter && input.length() - pos >= 6) { + return FormatCodeType.KYORI_HEX; + } else if (this.formats.characters.indexOf(legacy) != -1) { + return FormatCodeType.MOJANG_LEGACY; + } + return null; + } + + static @Nullable LegacyFormat legacyFormat(final char character) { + final int index = CharacterAndFormatSet.DEFAULT.characters.indexOf(character); + if (index != -1) { + final TextFormat format = CharacterAndFormatSet.DEFAULT.formats.get(index); + if (format instanceof NamedTextColor) { + return new LegacyFormat((NamedTextColor) format); + } else if (format instanceof TextDecoration) { + return new LegacyFormat((TextDecoration) format); + } else if (format instanceof Reset) { + return LegacyFormat.RESET; + } + } + return null; + } + + private @Nullable DecodedFormat decodeTextFormat(final char legacy, final String input, final int pos) { + final FormatCodeType foundFormat = this.determineFormatType(legacy, input, pos); + if (foundFormat == null) { + return null; + } + if (foundFormat == FormatCodeType.KYORI_HEX) { + final @Nullable TextColor parsed = tryParseHexColor(input.substring(pos, pos + 6)); + if (parsed != null) { + return new DecodedFormat(foundFormat, parsed); + } + } else if (foundFormat == FormatCodeType.MOJANG_LEGACY) { + return new DecodedFormat(foundFormat, this.formats.formats.get(this.formats.characters.indexOf(legacy))); + } else if (foundFormat == FormatCodeType.BUNGEECORD_UNUSUAL_HEX) { + final StringBuilder foundHex = new StringBuilder(6); + for (int i = pos - 1; i >= pos - 11; i -= 2) { + foundHex.append(input.charAt(i)); + } + final @Nullable TextColor parsed = tryParseHexColor(foundHex.reverse().toString()); + if (parsed != null) { + return new DecodedFormat(foundFormat, parsed); + } + } + return null; + } + + private static @Nullable TextColor tryParseHexColor(final String hexDigits) { + try { + final int color = Integer.parseInt(hexDigits, 16); + return TextColor.color(color); + } catch (final NumberFormatException ex) { + return null; + } + } + + private static boolean isHexTextColor(final TextFormat format) { + return format instanceof TextColor && !(format instanceof NamedTextColor); + } + + private @Nullable String toLegacyCode(TextFormat format) { + if (isHexTextColor(format)) { + final TextColor color = (TextColor) format; + if (this.hexColours) { + final String hex = String.format("%06x", color.value()); + if (this.useTerriblyStupidHexFormat) { + // ah yes, wonderful. A 14 digit long completely unreadable string. + final StringBuilder legacy = new StringBuilder(String.valueOf(LEGACY_BUNGEE_HEX_CHAR)); + for (int i = 0, length = hex.length(); i < length; i++) { + legacy.append(this.character).append(hex.charAt(i)); + } + return legacy.toString(); + } else { + // this is a bit nicer, hey? + return this.hexCharacter + hex; + } + } else { + if (!(color instanceof NamedTextColor)) { + // if we are not using hex colours, then convert the hex colour + // to the "nearest" possible named/standard text colour + format = TextColor.nearestColorTo(this.formats.colors, color); + } + } + } + final int index = this.formats.formats.indexOf(format); + if (index == -1) { + // this format was removed from the formats list + return null; + } + return Character.toString(this.formats.characters.charAt(index)); + } + + private TextComponent extractUrl(final TextComponent component) { + if (this.urlReplacementConfig == null) return component; + final Component newComponent = component.replaceText(this.urlReplacementConfig); + if (newComponent instanceof TextComponent) return (TextComponent) newComponent; + return Component.text().append(newComponent).build(); + } + + @Override + public @NotNull TextComponent deserialize(final @NotNull String input) { + int next = input.lastIndexOf(this.character, input.length() - 2); + if (next == -1) { + return this.extractUrl(Component.text(input)); + } + + final List parts = new ArrayList<>(); + + TextComponent.Builder current = null; + boolean reset = false; + + int pos = input.length(); + do { + final DecodedFormat decoded = this.decodeTextFormat(input.charAt(next + 1), input, next + 2); + if (decoded != null) { + final int from = next + (decoded.encodedFormat == FormatCodeType.KYORI_HEX ? 8 : 2); + if (from != pos) { + if (current != null) { + if (reset) { + parts.add(current.build()); + reset = false; + current = Component.text(); + } else { + current = Component.text().append(current.build()); + } + } else { + current = Component.text(); + } + + current.content(input.substring(from, pos)); + } else if (current == null) { + current = Component.text(); + } + + if (!reset) { + reset = applyFormat(current, decoded.format); + } + if (decoded.encodedFormat == FormatCodeType.BUNGEECORD_UNUSUAL_HEX) { + // BungeeCord hex characters are a repeating set of characters, all of which are also valid + // legacy Mojang chat colors. Subtract the number of characters in the format, and only then + // skip ahead. + next -= 12; + } + pos = next; + } + + next = input.lastIndexOf(this.character, next - 1); + } while (next != -1); + + if (current != null) { + parts.add(current.build()); + } + + final String remaining = pos > 0 ? input.substring(0, pos) : ""; + if (parts.size() == 1 && remaining.isEmpty()) { + return this.extractUrl(parts.get(0)); + } else { + Collections.reverse(parts); + return this.extractUrl(Component.text().content(remaining).append(parts).build()); + } + } + + @Override + public @NotNull String serialize(final @NotNull Component component) { + final Cereal state = new Cereal(); + this.flattener.flatten(component, state); + return state.toString(); + } + + private static boolean applyFormat(final TextComponent.@NotNull Builder builder, final @NotNull TextFormat format) { + if (format instanceof TextColor) { + builder.colorIfAbsent((TextColor) format); + return true; + } else if (format instanceof TextDecoration) { + builder.decoration((TextDecoration) format, TextDecoration.State.TRUE); + return false; + } else if (format instanceof Reset) { + return true; + } + throw new IllegalArgumentException(String.format("unknown format '%s'", format.getClass())); + } + + @Override + public @NotNull Builder toBuilder() { + return new BuilderImpl(this); + } + + // Are you hungry? + private final class Cereal implements FlattenerListener { + private final StringBuilder sb = new StringBuilder(); + private final StyleState style = new StyleState(); + private @Nullable TextFormat lastWritten; + private StyleState[] styles = new StyleState[8]; + private int head = -1; + + @Override + public void pushStyle(final @NotNull Style pushed) { + final int idx = ++this.head; + if (idx >= this.styles.length) { + this.styles = Arrays.copyOf(this.styles, this.styles.length * 2); + } + StyleState state = this.styles[idx]; + + if (state == null) { + this.styles[idx] = state = new StyleState(); + } + + if (idx > 0) { + // https://github.com/KyoriPowered/adventure/issues/287 + // https://github.com/KyoriPowered/adventure/pull/299 + state.set(this.styles[idx - 1]); + } else { + state.clear(); + } + + state.apply(pushed); + } + + @Override + public void component(final @NotNull String text) { + if (!text.isEmpty()) { + if (this.head < 0) throw new IllegalStateException("No style has been pushed!"); + + this.styles[this.head].applyFormat(); + + // If the message contains \n then go through and re-set the color after each by caching the last color + // Bedrock is dumb and resets the color after a newline + String[] lines = text.split("\n"); + if (lines.length > 1) { + for (int i = 0; i < lines.length; i++) { + if (i != 0) { + this.sb.append("\n"); + this.lastWritten = null; + this.styles[this.head].applyFullFormat(); + } + this.sb.append(lines[i]); + } + } else { + this.sb.append(text); + } + } + } + + @Override + public void popStyle(final @NotNull Style style) { + if (this.head-- < 0) { + throw new IllegalStateException("Tried to pop beyond what was pushed!"); + } + } + + void append(final @NotNull TextFormat format) { + if (this.lastWritten != format) { + final String legacyCode = BedrockComponentSerializerImpl.this.toLegacyCode(format); + if (legacyCode == null) { + return; + } + this.sb.append(BedrockComponentSerializerImpl.this.character).append(legacyCode); + } + this.lastWritten = format; + } + + @Override + public String toString() { + return this.sb.toString(); + } + + private final class StyleState { + private @Nullable TextColor color; + private final Set decorations; + private boolean needsReset; + + StyleState() { + this.decorations = EnumSet.noneOf(TextDecoration.class); + } + + void set(final @NotNull StyleState that) { + this.color = that.color; + this.decorations.clear(); + this.decorations.addAll(that.decorations); + } + + public void clear() { + this.color = null; + this.decorations.clear(); + } + + void apply(final @NotNull Style component) { + final TextColor color = component.color(); + if (color != null) { + this.color = color; + } + + for (int i = 0, length = DECORATIONS.length; i < length; i++) { + final TextDecoration decoration = DECORATIONS[i]; + switch (component.decoration(decoration)) { + case TRUE: + this.decorations.add(decoration); + break; + case FALSE: + if (this.decorations.remove(decoration)) { + this.needsReset = true; + } + break; + default: break; // ignored + } + } + } + + void applyFormat() { + final boolean colorChanged = this.color != Cereal.this.style.color; + if (this.needsReset) { + Cereal.this.append(Reset.INSTANCE); + this.needsReset = false; + } + + // If color changes, we need to do a full reset. + // Additionally, if the last thing to be appended was a reset then we need to re-apply everything. + if (colorChanged || Cereal.this.lastWritten == Reset.INSTANCE) { + this.applyFullFormat(); + return; + } + + // Does current have any decorations we don't have? + // Since there is no way to undo decorations, we need to reset these cases + if (!this.decorations.containsAll(Cereal.this.style.decorations)) { + this.applyFullFormat(); + return; + } + + // Apply new decorations + for (final TextDecoration decoration : this.decorations) { + if (Cereal.this.style.decorations.add(decoration)) { + Cereal.this.append(decoration); + } + } + } + + private void applyFullFormat() { + if (this.color != null) { + // Unlike Java Edition, the ChatFormatting is not reset when a ChatColor is added so we need to reset it manually + if (!Cereal.this.style.decorations.isEmpty()) { + Cereal.this.append(Reset.INSTANCE); + } + + Cereal.this.append(this.color); + } + Cereal.this.style.color = this.color; + + for (final TextDecoration decoration : this.decorations) { + Cereal.this.append(decoration); + } + + Cereal.this.style.decorations.clear(); + Cereal.this.style.decorations.addAll(this.decorations); + } + } + } + + static final class BuilderImpl implements Builder { + private char character = BedrockComponentSerializer.SECTION_CHAR; + private char hexCharacter = BedrockComponentSerializer.HEX_CHAR; + private TextReplacementConfig urlReplacementConfig = null; + private boolean hexColours = false; + private boolean useTerriblyStupidHexFormat = false; + private ComponentFlattener flattener = ComponentFlattener.basic(); + private CharacterAndFormatSet formats = CharacterAndFormatSet.DEFAULT; + + BuilderImpl() { + BUILDER.accept(this); // let service provider touch the builder before anybody else touches it + } + + BuilderImpl(final @NotNull BedrockComponentSerializerImpl serializer) { + this(); + this.character = serializer.character; + this.hexCharacter = serializer.hexCharacter; + this.urlReplacementConfig = serializer.urlReplacementConfig; + this.hexColours = serializer.hexColours; + this.useTerriblyStupidHexFormat = serializer.useTerriblyStupidHexFormat; + this.flattener = serializer.flattener; + this.formats = serializer.formats; + } + + @Override + public @NotNull Builder character(final char legacyCharacter) { + this.character = legacyCharacter; + return this; + } + + @Override + public @NotNull Builder hexCharacter(final char legacyHexCharacter) { + this.hexCharacter = legacyHexCharacter; + return this; + } + + @Override + public @NotNull Builder extractUrls() { + return this.extractUrls(DEFAULT_URL_PATTERN, null); + } + + @Override + public @NotNull Builder extractUrls(final @NotNull Pattern pattern) { + return this.extractUrls(pattern, null); + } + + @Override + public @NotNull Builder extractUrls(final @Nullable Style style) { + return this.extractUrls(DEFAULT_URL_PATTERN, style); + } + + @Override + public @NotNull Builder extractUrls(final @NotNull Pattern pattern, final @Nullable Style style) { + requireNonNull(pattern, "pattern"); + this.urlReplacementConfig = TextReplacementConfig.builder() + .match(pattern) + .replacement(url -> { + String clickUrl = url.content(); + if (!URL_SCHEME_PATTERN.matcher(clickUrl).find()) { + clickUrl = "http://" + clickUrl; + } + return (style == null ? url : url.style(style)).clickEvent(ClickEvent.openUrl(clickUrl)); + }) + .build(); + return this; + } + + @Override + public @NotNull Builder hexColors() { + this.hexColours = true; + return this; + } + + @Override + public @NotNull Builder useUnusualXRepeatedCharacterHexFormat() { + this.useTerriblyStupidHexFormat = true; // :( + return this; + } + + @Override + public @NotNull Builder flattener(final @NotNull ComponentFlattener flattener) { + this.flattener = requireNonNull(flattener, "flattener"); + return this; + } + + @Override + public @NotNull Builder formats(final @NotNull List formats) { + this.formats = CharacterAndFormatSet.of(formats); + return this; + } + + @Override + public @NotNull BedrockComponentSerializer build() { + return new BedrockComponentSerializerImpl(this.character, this.hexCharacter, this.urlReplacementConfig, this.hexColours, this.useTerriblyStupidHexFormat, this.flattener, this.formats); + } + } + + enum FormatCodeType { + MOJANG_LEGACY, + KYORI_HEX, + BUNGEECORD_UNUSUAL_HEX; + } + + static final class DecodedFormat { + final FormatCodeType encodedFormat; + final TextFormat format; + + private DecodedFormat(final FormatCodeType encodedFormat, final TextFormat format) { + if (format == null) { + throw new IllegalStateException("No format found"); + } + this.encodedFormat = encodedFormat; + this.format = format; + } + } +} diff --git a/text-serializer-bedrock/src/main/java/net/kyori/adventure/text/serializer/bedrock/CharacterAndFormat.java b/text-serializer-bedrock/src/main/java/net/kyori/adventure/text/serializer/bedrock/CharacterAndFormat.java new file mode 100644 index 000000000..2ab2340c2 --- /dev/null +++ b/text-serializer-bedrock/src/main/java/net/kyori/adventure/text/serializer/bedrock/CharacterAndFormat.java @@ -0,0 +1,249 @@ +/* + * This file is part of adventure, licensed under the MIT License. + * + * Copyright (c) 2017-2025 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.serializer.bedrock; + +import java.util.List; +import java.util.stream.Stream; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.format.TextColor; +import net.kyori.adventure.text.format.TextDecoration; +import net.kyori.adventure.text.format.TextFormat; +import net.kyori.examination.Examinable; +import net.kyori.examination.ExaminableProperty; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Unmodifiable; + +/** + * A combination of a {@code character}, a {@link TextFormat}, and if the character is {@link #caseInsensitive()}. + * + * @since 4.14.0 + */ +@ApiStatus.NonExtendable +public interface CharacterAndFormat extends Examinable { + /** + * Character and format pair representing {@link NamedTextColor#BLACK}. + * + * @since 4.14.0 + */ + CharacterAndFormat BLACK = characterAndFormat('0', NamedTextColor.BLACK, true); + /** + * Character and format pair representing {@link NamedTextColor#DARK_BLUE}. + * + * @since 4.14.0 + */ + CharacterAndFormat DARK_BLUE = characterAndFormat('1', NamedTextColor.DARK_BLUE, true); + /** + * Character and format pair representing {@link NamedTextColor#DARK_GREEN}. + * + * @since 4.14.0 + */ + CharacterAndFormat DARK_GREEN = characterAndFormat('2', NamedTextColor.DARK_GREEN, true); + /** + * Character and format pair representing {@link NamedTextColor#DARK_AQUA}. + * + * @since 4.14.0 + */ + CharacterAndFormat DARK_AQUA = characterAndFormat('3', NamedTextColor.DARK_AQUA, true); + /** + * Character and format pair representing {@link NamedTextColor#DARK_RED}. + * + * @since 4.14.0 + */ + CharacterAndFormat DARK_RED = characterAndFormat('4', NamedTextColor.DARK_RED, true); + /** + * Character and format pair representing {@link NamedTextColor#DARK_PURPLE}. + * + * @since 4.14.0 + */ + CharacterAndFormat DARK_PURPLE = characterAndFormat('5', NamedTextColor.DARK_PURPLE, true); + /** + * Character and format pair representing {@link NamedTextColor#GOLD}. + * + * @since 4.14.0 + */ + CharacterAndFormat GOLD = characterAndFormat('6', NamedTextColor.GOLD, true); + /** + * Character and format pair representing {@link NamedTextColor#GRAY}. + * + * @since 4.14.0 + */ + CharacterAndFormat GRAY = characterAndFormat('7', NamedTextColor.GRAY, true); + /** + * Character and format pair representing {@link NamedTextColor#DARK_GRAY}. + * + * @since 4.14.0 + */ + CharacterAndFormat DARK_GRAY = characterAndFormat('8', NamedTextColor.DARK_GRAY, true); + /** + * Character and format pair representing {@link NamedTextColor#BLUE}. + * + * @since 4.14.0 + */ + CharacterAndFormat BLUE = characterAndFormat('9', NamedTextColor.BLUE, true); + /** + * Character and format pair representing {@link NamedTextColor#GREEN}. + * + * @since 4.14.0 + */ + CharacterAndFormat GREEN = characterAndFormat('a', NamedTextColor.GREEN, true); + /** + * Character and format pair representing {@link NamedTextColor#AQUA}. + * + * @since 4.14.0 + */ + CharacterAndFormat AQUA = characterAndFormat('b', NamedTextColor.AQUA, true); + /** + * Character and format pair representing {@link NamedTextColor#RED}. + * + * @since 4.14.0 + */ + CharacterAndFormat RED = characterAndFormat('c', NamedTextColor.RED, true); + /** + * Character and format pair representing {@link NamedTextColor#LIGHT_PURPLE}. + * + * @since 4.14.0 + */ + CharacterAndFormat LIGHT_PURPLE = characterAndFormat('d', NamedTextColor.LIGHT_PURPLE, true); + /** + * Character and format pair representing {@link NamedTextColor#YELLOW}. + * + * @since 4.14.0 + */ + CharacterAndFormat YELLOW = characterAndFormat('e', NamedTextColor.YELLOW, true); + /** + * Character and format pair representing {@link NamedTextColor#WHITE}. + * + * @since 4.14.0 + */ + CharacterAndFormat WHITE = characterAndFormat('f', NamedTextColor.WHITE, true); + + CharacterAndFormat MINECOIN_GOLD = characterAndFormat('g', TextColor.color(221, 214, 5), true); + CharacterAndFormat MATERIAL_QUARTZ = characterAndFormat('h', TextColor.color(227, 212, 209), true); + CharacterAndFormat MATERIAL_IRON = characterAndFormat('i', TextColor.color(206, 202, 202), true); + CharacterAndFormat MATERIAL_NETHERITE = characterAndFormat('j', TextColor.color(68, 58, 59), true); + CharacterAndFormat MATERIAL_REDSTONE = characterAndFormat('m', TextColor.color(151, 22, 7), true); + CharacterAndFormat MATERIAL_COPPER = characterAndFormat('n', TextColor.color(180, 104, 77), true); + CharacterAndFormat MATERIAL_GOLD = characterAndFormat('p', TextColor.color(222, 177, 45), true); + CharacterAndFormat MATERIAL_EMERALD = characterAndFormat('q', TextColor.color(17, 160, 54), true); + CharacterAndFormat MATERIAL_DIAMOND = characterAndFormat('s', TextColor.color(44, 186, 168), true); + CharacterAndFormat MATERIAL_LAPIS = characterAndFormat('t', TextColor.color(33, 73, 123), true); + CharacterAndFormat MATERIAL_AMETHYST = characterAndFormat('u', TextColor.color(154, 92, 198), true); + CharacterAndFormat MATERIAL_RESIN = characterAndFormat('v', TextColor.color(235, 114, 20), true); + + /** + * Character and format pair representing {@link TextDecoration#OBFUSCATED}. + * + * @since 4.14.0 + */ + CharacterAndFormat OBFUSCATED = characterAndFormat('k', TextDecoration.OBFUSCATED, true); + /** + * Character and format pair representing {@link TextDecoration#BOLD}. + * + * @since 4.14.0 + */ + CharacterAndFormat BOLD = characterAndFormat('l', TextDecoration.BOLD, true); + /** + * Character and format pair representing {@link TextDecoration#ITALIC}. + * + * @since 4.14.0 + */ + CharacterAndFormat ITALIC = characterAndFormat('o', TextDecoration.ITALIC, true); + + /** + * Character and format pair representing {@link Reset#INSTANCE}. + * + * @since 4.14.0 + */ + CharacterAndFormat RESET = characterAndFormat('r', Reset.INSTANCE, true); + + /** + * Creates a new combination of a case-sensitive {@code character} and a {@link TextFormat}. + * + * @param character the character + * @param format the format + * @return a new character and format instance. + * @since 4.14.0 + */ + static @NotNull CharacterAndFormat characterAndFormat(final char character, final @NotNull TextFormat format) { + return characterAndFormat(character, format, false); + } + + /** + * Creates a new combination of a {@code character} and a {@link TextFormat}. + * + * @param character the character + * @param format the format + * @param caseInsensitive if the character is case-insensitive + * @return a new character and format instance. + * @since 4.17.0 + */ + static @NotNull CharacterAndFormat characterAndFormat(final char character, final @NotNull TextFormat format, final boolean caseInsensitive) { + return new CharacterAndFormatImpl(character, format, caseInsensitive); + } + + /** + * Gets an unmodifiable list of character and format instances containing all default vanilla formats. + * + * @return an unmodifiable list of character and format instances containing all default vanilla formats + * @since 4.14.0 + */ + @Unmodifiable + static @NotNull List defaults() { + return CharacterAndFormatImpl.Defaults.DEFAULTS; + } + + /** + * Gets the character. + * + * @return the character + * @since 4.14.0 + */ + char character(); + + /** + * Gets the format. + * + * @return the format + * @since 4.14.0 + */ + @NotNull TextFormat format(); + + /** + * If the {@link #character()} is case-insensitive. + * + * @return if the character is case-insensitive + * @since 4.17.0 + */ + boolean caseInsensitive(); + + @Override + default @NotNull Stream examinableProperties() { + return Stream.of( + ExaminableProperty.of("character", this.character()), + ExaminableProperty.of("format", this.format()), + ExaminableProperty.of("caseInsensitive", this.caseInsensitive()) + ); + } +} diff --git a/text-serializer-bedrock/src/main/java/net/kyori/adventure/text/serializer/bedrock/CharacterAndFormatImpl.java b/text-serializer-bedrock/src/main/java/net/kyori/adventure/text/serializer/bedrock/CharacterAndFormatImpl.java new file mode 100644 index 000000000..5f4b53692 --- /dev/null +++ b/text-serializer-bedrock/src/main/java/net/kyori/adventure/text/serializer/bedrock/CharacterAndFormatImpl.java @@ -0,0 +1,132 @@ +/* + * This file is part of adventure, licensed under the MIT License. + * + * Copyright (c) 2017-2025 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.serializer.bedrock; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import net.kyori.adventure.internal.Internals; +import net.kyori.adventure.text.format.TextFormat; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import static java.util.Objects.requireNonNull; + +final class CharacterAndFormatImpl implements CharacterAndFormat { + private final char character; + private final TextFormat format; + private final boolean caseInsensitive; + + CharacterAndFormatImpl(final char character, final @NotNull TextFormat format, final boolean caseInsensitive) { + this.character = character; + this.format = requireNonNull(format, "format"); + this.caseInsensitive = caseInsensitive; + } + + @Override + public char character() { + return this.character; + } + + @Override + public @NotNull TextFormat format() { + return this.format; + } + + @Override + public boolean caseInsensitive() { + return this.caseInsensitive; + } + + @Override + public boolean equals(final @Nullable Object other) { + if (this == other) return true; + if (!(other instanceof CharacterAndFormatImpl)) return false; + final CharacterAndFormatImpl that = (CharacterAndFormatImpl) other; + return this.character == that.character + && this.format.equals(that.format) + && this.caseInsensitive == that.caseInsensitive; + } + + @Override + public int hashCode() { + int result = this.character; + result = 31 * result + this.format.hashCode(); + result = 31 * result + Boolean.hashCode(this.caseInsensitive); + return result; + } + + @Override + public @NotNull String toString() { + return Internals.toString(this); + } + + static final class Defaults { + static final List DEFAULTS = createDefaults(); + + private Defaults() { + } + + @SuppressWarnings("DuplicatedCode") + static List createDefaults() { + final List formats = new ArrayList<>(27 + 3 + 1); // colours + decorations + reset + + formats.add(BLACK); + formats.add(DARK_BLUE); + formats.add(DARK_GREEN); + formats.add(DARK_AQUA); + formats.add(DARK_RED); + formats.add(DARK_PURPLE); + formats.add(GOLD); + formats.add(GRAY); + formats.add(DARK_GRAY); + formats.add(BLUE); + formats.add(GREEN); + formats.add(AQUA); + formats.add(RED); + formats.add(LIGHT_PURPLE); + formats.add(YELLOW); + formats.add(WHITE); + formats.add(MINECOIN_GOLD); + formats.add(MATERIAL_QUARTZ); + formats.add(MATERIAL_IRON); + formats.add(MATERIAL_NETHERITE); + formats.add(MATERIAL_REDSTONE); + formats.add(MATERIAL_COPPER); + formats.add(MATERIAL_GOLD); + formats.add(MATERIAL_EMERALD); + formats.add(MATERIAL_LAPIS); + formats.add(MATERIAL_AMETHYST); + formats.add(MATERIAL_RESIN); + + formats.add(OBFUSCATED); + formats.add(BOLD); + formats.add(ITALIC); + + formats.add(RESET); + + return Collections.unmodifiableList(formats); + } + } +} diff --git a/text-serializer-bedrock/src/main/java/net/kyori/adventure/text/serializer/bedrock/CharacterAndFormatSet.java b/text-serializer-bedrock/src/main/java/net/kyori/adventure/text/serializer/bedrock/CharacterAndFormatSet.java new file mode 100644 index 000000000..c23b6438a --- /dev/null +++ b/text-serializer-bedrock/src/main/java/net/kyori/adventure/text/serializer/bedrock/CharacterAndFormatSet.java @@ -0,0 +1,87 @@ +/* + * This file is part of adventure, licensed under the MIT License. + * + * Copyright (c) 2017-2025 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.serializer.bedrock; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import net.kyori.adventure.text.format.TextColor; +import net.kyori.adventure.text.format.TextFormat; + +final class CharacterAndFormatSet { + static final CharacterAndFormatSet DEFAULT = of(CharacterAndFormat.defaults()); + final List formats; + final List colors; + final String characters; + + static CharacterAndFormatSet of(final List pairs) { + final int size = pairs.size(); + final List colors = new ArrayList<>(); + final List formats = new ArrayList<>(size); + final StringBuilder characters = new StringBuilder(size); + for (int i = 0; i < size; i++) { + final CharacterAndFormat pair = pairs.get(i); + final char character = pair.character(); + final TextFormat format = pair.format(); + final boolean formatIsTextColor = format instanceof TextColor; + + // First, add the "standard" character. + characters.append(character); + formats.add(format); + if (formatIsTextColor) { + colors.add((TextColor) format); + } + + // If the character is case-insensitive, we need to add the other character too. + if (pair.caseInsensitive()) { + boolean added = false; + + if (Character.isUpperCase(character)) { + characters.append(Character.toLowerCase(character)); + added = true; + } else if (Character.isLowerCase(character)) { + characters.append(Character.toUpperCase(character)); + added = true; + } + + if (added) { + formats.add(format); + if (formatIsTextColor) { + colors.add((TextColor) format); + } + } + } + } + if (formats.size() != characters.length()) { + throw new IllegalStateException("formats length differs from characters length"); + } + return new CharacterAndFormatSet(Collections.unmodifiableList(formats), Collections.unmodifiableList(colors), characters.toString()); + } + + CharacterAndFormatSet(final List formats, final List colors, final String characters) { + this.formats = formats; + this.colors = colors; + this.characters = characters; + } +} diff --git a/text-serializer-bedrock/src/main/java/net/kyori/adventure/text/serializer/bedrock/LegacyFormat.java b/text-serializer-bedrock/src/main/java/net/kyori/adventure/text/serializer/bedrock/LegacyFormat.java new file mode 100644 index 000000000..b31615575 --- /dev/null +++ b/text-serializer-bedrock/src/main/java/net/kyori/adventure/text/serializer/bedrock/LegacyFormat.java @@ -0,0 +1,127 @@ +/* + * This file is part of adventure, licensed under the MIT License. + * + * Copyright (c) 2017-2025 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.serializer.bedrock; + +import java.util.Objects; +import java.util.stream.Stream; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.format.TextColor; +import net.kyori.adventure.text.format.TextDecoration; +import net.kyori.examination.Examinable; +import net.kyori.examination.ExaminableProperty; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/* + * This is a hack. + */ + +/** + * A legacy format. + * + * @since 4.0.0 + */ +public final class LegacyFormat implements Examinable { + static final LegacyFormat RESET = new LegacyFormat(true); + private final @Nullable NamedTextColor color; + private final @Nullable TextDecoration decoration; + private final boolean reset; + + /* + * Separate constructors to ensure a format can never be more than one thing. + */ + + LegacyFormat(final @Nullable NamedTextColor color) { + this.color = color; + this.decoration = null; + this.reset = false; + } + + LegacyFormat(final @Nullable TextDecoration decoration) { + this.color = null; + this.decoration = decoration; + this.reset = false; + } + + private LegacyFormat(final boolean reset) { + this.color = null; + this.decoration = null; + this.reset = reset; + } + + /** + * Gets the color. + * + * @return the color + * @since 4.0.0 + */ + public @Nullable TextColor color() { + return this.color; + } + + /** + * Gets the decoration. + * + * @return the decoration + * @since 4.0.0 + */ + public @Nullable TextDecoration decoration() { + return this.decoration; + } + + /** + * Gets if this format is a reset. + * + * @return {@code true} if a reset, {@code false} otherwise + * @since 4.0.0 + */ + public boolean reset() { + return this.reset; + } + + @Override + public boolean equals(final @Nullable Object other) { + if (this == other) return true; + if (other == null || this.getClass() != other.getClass()) return false; + final LegacyFormat that = (LegacyFormat) other; + return this.color == that.color && this.decoration == that.decoration && this.reset == that.reset; + } + + @Override + public int hashCode() { + int result = Objects.hashCode(this.color); + result = (31 * result) + Objects.hashCode(this.decoration); + result = (31 * result) + Boolean.hashCode(this.reset); + return result; + } + + @Override + public @NotNull Stream examinableProperties() { + return Stream.of( + ExaminableProperty.of("color", this.color), + ExaminableProperty.of("decoration", this.decoration), + ExaminableProperty.of("reset", this.reset) + ); + } +} diff --git a/text-serializer-bedrock/src/main/java/net/kyori/adventure/text/serializer/bedrock/Reset.java b/text-serializer-bedrock/src/main/java/net/kyori/adventure/text/serializer/bedrock/Reset.java new file mode 100644 index 000000000..99f9deb50 --- /dev/null +++ b/text-serializer-bedrock/src/main/java/net/kyori/adventure/text/serializer/bedrock/Reset.java @@ -0,0 +1,35 @@ +/* + * This file is part of adventure, licensed under the MIT License. + * + * Copyright (c) 2017-2025 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.serializer.bedrock; + +import net.kyori.adventure.text.format.TextFormat; + +/** + * The reset directive. + * + * @since 4.14.0 + */ +public enum Reset implements TextFormat { + INSTANCE; +} diff --git a/text-serializer-bedrock/src/main/java/net/kyori/adventure/text/serializer/bedrock/package-info.java b/text-serializer-bedrock/src/main/java/net/kyori/adventure/text/serializer/bedrock/package-info.java new file mode 100644 index 000000000..4ac1ac117 --- /dev/null +++ b/text-serializer-bedrock/src/main/java/net/kyori/adventure/text/serializer/bedrock/package-info.java @@ -0,0 +1,27 @@ +/* + * This file is part of adventure, licensed under the MIT License. + * + * Copyright (c) 2017-2022 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. + */ +/** + * Bedrock text based serialization and deserialization. + */ +package net.kyori.adventure.text.serializer.bedrock; diff --git a/text-serializer-bedrock/src/test/java/net/kyori/adventure/text/serializer/bedrock/BedrockComponentSerializerTest.java b/text-serializer-bedrock/src/test/java/net/kyori/adventure/text/serializer/bedrock/BedrockComponentSerializerTest.java new file mode 100644 index 000000000..84f474ac1 --- /dev/null +++ b/text-serializer-bedrock/src/test/java/net/kyori/adventure/text/serializer/bedrock/BedrockComponentSerializerTest.java @@ -0,0 +1,406 @@ +/* + * This file is part of adventure, licensed under the MIT License. + * + * Copyright (c) 2017-2025 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.serializer.bedrock; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.TextComponent; +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.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class BedrockComponentSerializerTest { + @Test + void testSimpleColor() { + final String expected = "§eDoctorMad9952 joined the game"; + final String actual = BedrockComponentSerializer.bedrock().serialize(Component.text("DoctorMad9952 joined the game", NamedTextColor.YELLOW)); + assertEquals(expected, actual); + } + + @Test + void testColorCodeReset() { + final String expected = "§7[§eH§7]§f §7§lGUEST §r§9»§7 §frtm516§7: §fThis is an amazing bedrock test message"; + final String actual = BedrockComponentSerializer + .bedrock() + .serialize(Component.text("") + .append(Component.text("") + .append(Component.text("[", NamedTextColor.GRAY)) + .append(Component.text("H", NamedTextColor.YELLOW)) + .append(Component.text("]", NamedTextColor.GRAY)) + .append(Component.text(" ", NamedTextColor.WHITE)) + .append(Component.text("GUEST", TextColor.color(0xb7b7b7)).decoration(TextDecoration.BOLD, true)) + ) + .append(Component.text("") + .append(Component.text(" ").decoration(TextDecoration.BOLD, true)) + .append(Component.text("»", NamedTextColor.BLUE)) + .append(Component.text(" ", NamedTextColor.GRAY)) + ) + .append(Component.text("") + .append(Component.text("rtm516", NamedTextColor.WHITE)) + .append(Component.text(": ", NamedTextColor.GRAY)) + .append(Component.text("", NamedTextColor.WHITE)) + ) + .append(Component.text("This is an amazing bedrock test message", NamedTextColor.WHITE)) + ); + assertEquals(expected, actual); + } + + @Test + void testNewlineColorRestore() { + final String expected = "§eContribute to a weekly community goal.\n" + + "§eAll participants will receive a reward\n" + + "§eand the top 3 will get extra bonus prizes!"; + + final String actual = BedrockComponentSerializer + .bedrock() + .serialize(Component.text("Contribute to a weekly community goal.\nAll participants will receive a reward\nand the top 3 will get extra bonus prizes!", NamedTextColor.YELLOW)); + + assertEquals(expected, actual); + } + +// @Test +// void testSimpleFrom() { +// final TextComponent component = Component.text("foo"); +// assertEquals(component, BedrockComponentSerializer.legacySection().deserialize("foo")); +// } +// +// @Test +// void testFromColor() { +// final TextComponent component = Component.text().content("") +// .append(Component.text("foo").color(NamedTextColor.GREEN).decoration(TextDecoration.BOLD, TextDecoration.State.TRUE)) +// .append(Component.text("bar").color(NamedTextColor.BLUE)) +// .build(); +// +// assertEquals(component, BedrockComponentSerializer.legacy('&').deserialize("&a&lfoo&9bar")); +// } +// +// @Test +// void testFromColorOverride() { +// final TextComponent component = Component.text("foo").color(NamedTextColor.BLUE); +// +// assertEquals(component, BedrockComponentSerializer.legacy('&').deserialize("&a&9foo")); +// } +// +// @Test +// void testInvalidColors() { +// // https://github.com/KyoriPowered/adventure/issues/266 +// assertEquals(Component.text("&q"), BedrockComponentSerializer.legacyAmpersand().deserialize("&q")); +// assertEquals(Component.text("&#no"), BedrockComponentSerializer.legacyAmpersand().deserialize("&#no")); +// } +// +// @Test +// void testJustColor() { +// assertEquals(Component.text("", TextColor.color(0xabcdef)), BedrockComponentSerializer.legacyAmpersand().deserialize("&#abcdef")); +// } +// +// @Test +// void testResetOverride() { +// final TextComponent component = Component.text().content("") +// .append(Component.text("foo").color(NamedTextColor.GREEN).decoration(TextDecoration.BOLD, TextDecoration.State.TRUE)) +// .append(Component.text("bar").color(NamedTextColor.DARK_GRAY)) +// .build(); +// +// assertEquals(component, BedrockComponentSerializer.legacy('&').deserialize("&a&lfoo&r&8bar")); +// } +// +// @SuppressWarnings("checkstyle:AvoidEscapedUnicodeCharacters") +// @Test +// void testCompound() { +// final TextComponent component = Component.text() +// .content("hi there ") +// .append(Component.text().content("this bit is green ") +// .color(NamedTextColor.GREEN) +// .build()) +// .append(Component.text("this isn't ").style(Style.empty())) +// .append(Component.text().content("and woa, this is again") +// .color(NamedTextColor.GREEN) +// .build()) +// .build(); +// +// assertEquals("hi there &athis bit is green &rthis isn't &aand woa, this is again", BedrockComponentSerializer.legacy('&').serialize(component)); +// } +// +// @Test +// void testToLegacy() { +// final TextComponent c1 = Component.text().content("hi") +// .decoration(TextDecoration.BOLD, TextDecoration.State.TRUE) +// .append( +// Component.text("foo") +// .color(NamedTextColor.GREEN) +// .decoration(TextDecoration.BOLD, TextDecoration.State.FALSE) +// ) +// .append( +// Component.text("bar") +// .color(NamedTextColor.BLUE) +// ) +// .append(Component.text("baz")) +// .build(); +// assertEquals("§lhi§afoo§9§lbar§r§lbaz", BedrockComponentSerializer.legacySection().serialize(c1)); +// +// final TextComponent c2 = Component.text() +// .content("") +// .color(NamedTextColor.YELLOW) +// .append(Component.text() +// .content("Hello ") +// .append( +// Component.text() +// .content("world") +// .color(NamedTextColor.GREEN) +// .build() +// ) +// .append(Component.text("!")) // Should be yellow +// .build() +// ) +// .build(); +// assertEquals("§eHello §aworld§e!", BedrockComponentSerializer.legacySection().serialize(c2)); +// +// final TextComponent c3 = Component.text() +// .content("") +// .decoration(TextDecoration.BOLD, true) +// .append( +// Component.text() +// .content("") +// .color(NamedTextColor.YELLOW) +// .append(Component.text() +// .content("Hello ") +// .append( +// Component.text() +// .content("world") +// .color(NamedTextColor.GREEN) +// .build() +// ) +// .append(Component.text("!")) +// .build() +// ) +// .build()) +// .build(); +// assertEquals("§e§lHello §a§lworld§e§l!", BedrockComponentSerializer.legacySection().serialize(c3)); +// } +// +// @Test +// void testToLegacyWithHexColor() { +// final TextComponent c0 = Component.text("Kittens!", TextColor.color(0xffefd5)); +// assertEquals("§#ffefd5Kittens!", BedrockComponentSerializer.builder().hexColors().build().serialize(c0)); +// } +// +// @Test +// void testToLegacyWithHexColorDownsampling() { +// final TextComponent comp = Component.text("purr", TextColor.color(0xff0000)); +// assertEquals("§4purr", BedrockComponentSerializer.builder().build().serialize(comp)); +// } +// +// @Test +// void testFromLegacyWithHexColor() { +// final TextComponent component = Component.text().content("") +// .append(Component.text("pretty").color(TextColor.fromHexString("#ffb6c1"))) +// .append(Component.text("in").color(TextColor.fromHexString("#ff69b4")).decoration(TextDecoration.BOLD, TextDecoration.State.TRUE)) +// .append(Component.text("pink").color(TextColor.fromHexString("#ffc0cb"))) +// .build(); +// assertEquals(component, BedrockComponentSerializer.builder().character('&').hexColors().build().deserialize("&#ffb6c1pretty&#ff69b4&lin&#ffc0cbpink")); +// } +// +// @Test +// void testToLegacyWithHexColorTerribleFormat() { +// final TextComponent c0 = Component.text("Kittens!", TextColor.color(0xffefd5)); +// assertEquals("§x§f§f§e§f§d§5Kittens!", BedrockComponentSerializer.builder().hexColors().useUnusualXRepeatedCharacterHexFormat().build().serialize(c0)); +// } +// +// @Test +// void testFromLegacyWithHexColorTerribleFormat() { +// final TextComponent expected = Component.text("Kittens!", TextColor.color(0xffefd5)); +// assertEquals(expected, BedrockComponentSerializer.builder().hexColors().build().deserialize("§x§f§f§e§f§d§5Kittens!")); +// } +// +// @Test +// void testFromLegacyWithHexColorTerribleFormatMixed() { +// final TextComponent expected = Component.text().content("") +// .append(Component.text("Hugs and ", NamedTextColor.RED)) +// .append(Component.text("Kittens!", TextColor.color(0xffefd5))) +// .build(); +// assertEquals(expected, BedrockComponentSerializer.builder().hexColors().build().deserialize("§cHugs and §x§f§f§e§f§d§5Kittens!")); +// } +// +// @Test +// void testFromLegacyWithHexColorTerribleFormatEnsureProperLookahead() { +// final TextComponent expected = Component.text().content("") +// .append(Component.text("Hugs and ", NamedTextColor.RED)) +// .append(Component.text("Kittens!", NamedTextColor.DARK_PURPLE)) +// .build(); +// assertEquals(expected, BedrockComponentSerializer.builder().hexColors().build().deserialize("§cHugs and §f§f§e§f§d§5Kittens!")); +// } +// +// @Test +// void testFromLegacyWithHexColorTerribleFormatEnsureMultipleColorsWork() { +// final TextComponent expected = Component.text().content("Happy with ") +// .append(Component.text("Lavender and ", TextColor.color(0x6b4668))) +// .append(Component.text("Cyan!", TextColor.color(0xffefd5))) +// .build(); +// assertEquals(expected, BedrockComponentSerializer.builder().hexColors().build().deserialize("Happy with §x§6§b§4§6§6§8Lavender and §x§f§f§e§f§d§5Cyan!")); +// } +// +// @Test +// void testFromLegacyWithHexColorTerribleFormatHangingCharacter() { +// final TextComponent expected = Component.text().content("§x") +// .append(Component.text("Kittens!", NamedTextColor.YELLOW)) +// .build(); +// assertEquals(expected, BedrockComponentSerializer.builder().hexColors().build().deserialize("§x§eKittens!")); +// } +// +// // https://github.com/KyoriPowered/adventure/issues/108 +// @Test +// void testFromLegacyWithNewline() { +// final TextComponent comp = Component.text().content("One: Test ") +// .append(Component.text("String\nTwo: ", NamedTextColor.GREEN)) +// .append(Component.text("Test ", NamedTextColor.AQUA)) +// .append(Component.text("String", NamedTextColor.GREEN)) +// .build(); +// final String in = "One: Test &aString\nTwo: &bTest &aString"; +// assertEquals(comp, BedrockComponentSerializer.legacy('&').deserialize(in)); +// } +// +// // https://github.com/KyoriPowered/adventure/issues/108 +// @Test +// void testBeginningTextUnformatted() { +// final String input = "Test &cString"; +// final TextComponent expected = Component.text().content("Test ") +// .append(Component.text("String", NamedTextColor.RED)) +// .build(); +// +// assertEquals(expected, BedrockComponentSerializer.legacy(BedrockComponentSerializer.AMPERSAND_CHAR).deserialize(input)); +// } +// +// // https://github.com/KyoriPowered/adventure/issues/92 +// @Test +// void testStackedFormattingFlags() { +// final String input = "§r§r§c§k||§e§lProfile§c§k||"; +// final TextComponent output = Component.text().append( +// Component.text("||", Style.style(NamedTextColor.RED, TextDecoration.OBFUSCATED)), +// Component.text("Profile", Style.style(NamedTextColor.YELLOW, TextDecoration.BOLD)), +// Component.text("||", Style.style(NamedTextColor.RED, TextDecoration.OBFUSCATED)) +// ).build(); +// assertEquals(output, BedrockComponentSerializer.legacySection().deserialize(input)); +// } +// +// @Test +// void testResetClearsColorInSameBlock() { +// final String input = "§c§rCleared"; +// final TextComponent output = Component.text("Cleared"); +// assertEquals(output, BedrockComponentSerializer.legacySection().deserialize(input)); +// } +// +// @Test +// void testParseColourChar() { +// final LegacyFormat lf = BedrockComponentSerializer.parseChar('5'); +// assertNotNull(lf); +// assertEquals(NamedTextColor.DARK_PURPLE, lf.color()); +// assertNull(lf.decoration()); +// assertFalse(lf.reset()); +// } +// +// @Test +// void testParseDecorationChar() { +// final LegacyFormat lf = BedrockComponentSerializer.parseChar('l'); +// assertNotNull(lf); +// assertNull(lf.color()); +// assertEquals(TextDecoration.BOLD, lf.decoration()); +// assertFalse(lf.reset()); +// } +// +// @Test +// void testParseResetChar() { +// final LegacyFormat lf = BedrockComponentSerializer.parseChar('r'); +// assertNotNull(lf); +// assertNull(lf.color()); +// assertNull(lf.decoration()); +// assertTrue(lf.reset()); +// } +// +// // https://github.com/KyoriPowered/adventure/issues/287 +// @Test +// void testNoRedundantReset() { +// final String text = "&a&lP&eaper"; +// final Component expectedDeserialized = Component.text() +// .append(Component.text("P", NamedTextColor.GREEN, TextDecoration.BOLD)) +// .append(Component.text("aper", NamedTextColor.YELLOW)) +// .build(); +// final Component deserialized = BedrockComponentSerializer.legacyAmpersand().deserialize(text); +// +// assertEquals(expectedDeserialized, deserialized); +// +// final String roundtripped = BedrockComponentSerializer.legacyAmpersand().serialize(deserialized); +// assertEquals(text, roundtripped); +// } +// +// @Test +// void testInvalidHexStringsPassedThrough() { +// final String text = "Hello&#hellos world"; +// final Component deserialized = BedrockComponentSerializer.legacyAmpersand().deserialize(text); +// +// assertEquals(Component.text(text), deserialized); +// } +// +// @Test +// void testNullTextFormat() { +// final List formats = new ArrayList<>(CharacterAndFormat.defaults()); +// formats.remove(CharacterAndFormat.STRIKETHROUGH); +// final BedrockComponentSerializer serializer = BedrockComponentSerializer.legacySection().toBuilder().formats(formats).build(); +// +// final Component strikethough = Component.text("Hello World", Style.style(TextDecoration.STRIKETHROUGH)); +// final String serialized = serializer.serialize(strikethough); +// assertEquals(serialized, "Hello World"); +// } +// +// // https://github.com/KyoriPowered/adventure/issues/1043 +// @Test +// void testCaseInsensitivity() { +// final Component expected = Component.text("pop4959", NamedTextColor.YELLOW); +// final Component lowercaseActual = BedrockComponentSerializer.legacyAmpersand().deserialize("&epop4959"); +// assertEquals(expected, lowercaseActual); +// +// final Component uppercaseActual = BedrockComponentSerializer.legacyAmpersand().deserialize("&Epop4959"); +// assertEquals(expected, uppercaseActual); +// } +// +// @Test +// void testCaseSensitivity() { +// final Component expected = Component.text("&Epop4959"); +// final Component lowercaseActual = BedrockComponentSerializer +// .legacyAmpersand() +// .toBuilder() +// .formats(Collections.singletonList(CharacterAndFormat.characterAndFormat('e', NamedTextColor.YELLOW))) +// .build() +// .deserialize("&Epop4959"); +// assertEquals(expected, lowercaseActual); +// } +} diff --git a/text-serializer-bedrock/src/test/java/net/kyori/adventure/text/serializer/bedrock/LinkingBedrockComponentSerializerTest.java b/text-serializer-bedrock/src/test/java/net/kyori/adventure/text/serializer/bedrock/LinkingBedrockComponentSerializerTest.java new file mode 100644 index 000000000..6743839de --- /dev/null +++ b/text-serializer-bedrock/src/test/java/net/kyori/adventure/text/serializer/bedrock/LinkingBedrockComponentSerializerTest.java @@ -0,0 +1,141 @@ +/* + * This file is part of adventure, licensed under the MIT License. + * + * Copyright (c) 2017-2025 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.serializer.bedrock; + +import java.util.regex.Pattern; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.TextComponent; +import net.kyori.adventure.text.event.ClickEvent; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.format.Style; +import net.kyori.adventure.text.format.TextDecoration; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class LinkingBedrockComponentSerializerTest { +// @Test +// void testSimpleFrom() { +// assertEquals(Component.text("foo"), BedrockComponentSerializer.builder().extractUrls().build().deserialize("foo")); +// } +// +// @Test +// void testBareUrl() { +// final String bareUrl = "https://www.example.com"; +// final TextComponent expectedNonLinkify = Component.text(bareUrl); +// assertEquals(expectedNonLinkify, BedrockComponentSerializer.legacySection().deserialize(bareUrl)); +// final TextComponent expectedBareUrl = Component.text(bareUrl) +// .clickEvent(ClickEvent.openUrl(bareUrl)); +// assertEquals(expectedBareUrl, BedrockComponentSerializer.builder().extractUrls().build().deserialize(bareUrl)); +// } +// +// @Test +// void testPrefixUrl() { +// final String bareUrl = "https://www.example.com"; +// final String hasPrefix = "did you hear about https://www.example.com"; +// final TextComponent expectedHasPrefix = Component.text().content("did you hear about ") +// .append(Component.text(bareUrl).clickEvent(ClickEvent.openUrl(bareUrl))) +// .build(); +// assertEquals(expectedHasPrefix, BedrockComponentSerializer.builder().character('&').extractUrls().build().deserialize(hasPrefix)); +// } +// +// @Test +// void testPrefixSuffixUrl() { +// final String bareUrl = "https://www.example.com"; +// final String hasPrefixSuffix = "did you hear about https://www.example.com? they're really cool"; +// final TextComponent expectedHasPrefixSuffix = Component.text().content("did you hear about ") +// .append(Component.text(bareUrl).clickEvent(ClickEvent.openUrl(bareUrl))) +// .append(Component.text("? they're really cool")) +// .build(); +// assertEquals(expectedHasPrefixSuffix, BedrockComponentSerializer.builder().character('&').extractUrls().build().deserialize(hasPrefixSuffix)); +// } +// +// @Test +// void testPrefixSuffixUrlAndColors() { +// final String bareUrl = "https://www.example.com"; +// final String hasPrefixSuffixColors = "&adid you hear about &chttps://www.example.com? &9they're really cool"; +// final TextComponent expectedHasPrefixSuffixColors = Component.text().content("") +// .append(Component.text("did you hear about ", NamedTextColor.GREEN)) +// .append(Component.text(b -> b.append(Component.text("https://www.example.com").clickEvent(ClickEvent.openUrl(bareUrl))) +// .append(Component.text("? ")) +// .color(NamedTextColor.RED))) +// .append(Component.text("they're really cool", NamedTextColor.BLUE)) +// .build(); +// assertEquals(expectedHasPrefixSuffixColors, BedrockComponentSerializer.builder().character('&').extractUrls().build().deserialize(hasPrefixSuffixColors)); +// } +// +// @Test +// void testSchemelessUrl() { +// final String schemelessBareUrl = "example.com"; +// final String bareUrl = "http://example.com"; +// final TextComponent expectedHasScheme = Component.text(schemelessBareUrl).clickEvent(ClickEvent.openUrl(bareUrl)); +// assertEquals(expectedHasScheme, BedrockComponentSerializer.builder().extractUrls().build().deserialize(schemelessBareUrl)); +// } +// +// @Test +// void testMailUrl() { +// final String mailUrl = "mailto:example@example.com"; +// final TextComponent expectedComponent = Component.text(mailUrl).clickEvent(ClickEvent.openUrl(mailUrl)); +// assertEquals(expectedComponent, BedrockComponentSerializer.builder().extractUrls().extractUrls( +// Pattern.compile("([a-z][a-z0-9+\\-.]*:)?([-\\w_.@]+\\.\\w{2,})(/\\S*)?")).build().deserialize(mailUrl)); +// } +// +// @Test +// void testMultipleUrls() { +// final String manyUrls = "go to https://www.example.com and https://www.example.net for cat videos"; +// final TextComponent expectedManyUrls = Component.text().content("go to ") +// .append(Component.text("https://www.example.com").clickEvent(ClickEvent.openUrl("https://www.example.com"))) +// .append(Component.text(" and ")) +// .append(Component.text("https://www.example.net").clickEvent(ClickEvent.openUrl("https://www.example.net"))) +// .append(Component.text(" for cat videos")) +// .build(); +// assertEquals(expectedManyUrls, BedrockComponentSerializer.builder().character('&').extractUrls().build().deserialize(manyUrls)); +// } +// +// @Test +// void testLinkifyWithStyle() { +// final Style testStyle = Style.style(NamedTextColor.GREEN, TextDecoration.UNDERLINED); +// final BedrockComponentSerializer serializer = BedrockComponentSerializer.builder().character('&').extractUrls(testStyle).build(); +// +// final String bareUrl = "https://www.example.com"; +// final Component expectedBareUrl = Component.text(bareUrl).style(testStyle.clickEvent(ClickEvent.openUrl(bareUrl))); +// assertEquals(expectedBareUrl, serializer.deserialize(bareUrl)); +// +// final String hasPrefixSuffix = "did you hear about https://www.example.com? they're really cool"; +// final TextComponent expectedHasPrefixSuffix = Component.text().content("did you hear about ") +// .append(Component.text(bareUrl).style(testStyle.clickEvent(ClickEvent.openUrl(bareUrl)))) +// .append(Component.text("? they're really cool")) +// .build(); +// assertEquals(expectedHasPrefixSuffix, serializer.deserialize(hasPrefixSuffix)); +// +// final String manyUrls = "go to https://www.example.com and https://www.example.net for cat videos"; +// final TextComponent expectedManyUrls = Component.text().content("go to ") +// .append(Component.text("https://www.example.com").style(testStyle.clickEvent(ClickEvent.openUrl("https://www.example.com")))) +// .append(Component.text(" and ")) +// .append(Component.text("https://www.example.net").style(testStyle.clickEvent(ClickEvent.openUrl("https://www.example.net")))) +// .append(Component.text(" for cat videos")) +// .build(); +// assertEquals(expectedManyUrls, serializer.deserialize(manyUrls)); +// } +}