diff --git a/src/main/java/gg/gemstone/component/translator/LegacyFormattingCodeTranslator.java b/src/main/java/gg/gemstone/component/translator/LegacyFormattingCodeTranslator.java index 95a9e45..3f29d93 100644 --- a/src/main/java/gg/gemstone/component/translator/LegacyFormattingCodeTranslator.java +++ b/src/main/java/gg/gemstone/component/translator/LegacyFormattingCodeTranslator.java @@ -17,6 +17,8 @@ package gg.gemstone.component.translator; +import static gg.gemstone.component.translator.MiniMessageTags.findTagEnd; + import java.util.ArrayDeque; import java.util.Deque; import java.util.Map; @@ -297,28 +299,4 @@ private int flushInnerLegacyTags(Deque legacyStack, StringBuilder output } return 0; } - - private int findTagEnd(String input, int start) { - char quoteChar = 0; - - for (int i = start + 1; i < input.length(); i++) { - char c = input.charAt(i); - - if (quoteChar != 0) { - // Inside a quoted string - only look for the matching closing quote. - if (c == quoteChar) { - quoteChar = 0; - } - } else if (c == '\'' || c == '"') { - quoteChar = c; - } else if (c == '>') { - return i; - } else if (c == '<' && i != start) { - // Unquoted nested '<' with no prior '>' - not a valid tag, bail out. - return -1; - } - } - - return -1; - } } diff --git a/src/main/java/gg/gemstone/component/translator/MiniMessageTags.java b/src/main/java/gg/gemstone/component/translator/MiniMessageTags.java new file mode 100644 index 0000000..cf8354c --- /dev/null +++ b/src/main/java/gg/gemstone/component/translator/MiniMessageTags.java @@ -0,0 +1,117 @@ +/* + * Copyright (C) 2026 GemstoneGG/Component Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package gg.gemstone.component.translator; + +import java.util.function.UnaryOperator; + +/** + * Helpers for working with MiniMessage tag spans inside a raw string. + * + *

These let translators reason about which parts of a string are MiniMessage tags (everything + * between a {@code <} and its matching {@code >}) and which parts are plain text. By transforming + * only the plain-text runs, a translator never has to defend its regex against hex codes or other + * tokens that legitimately appear inside a tag (e.g. {@code } or + * {@code }), which is otherwise impossible to express reliably with a lookbehind. + */ +final class MiniMessageTags { + + private MiniMessageTags() { + } + + /** + * Applies {@code transform} to every run of text that lies outside MiniMessage tag spans, + * copying tag spans through unchanged. + * + *

A tag span is a {@code <...>} sequence recognised by {@link #findTagEnd(String, int)}. Any + * {@code <} that does not open a well-formed tag (no matching {@code >}, or an unquoted nested + * {@code <}) is treated as ordinary text and handed to {@code transform} along with its + * surrounding characters. + * + * @param input the string to process + * @param transform the transformation to apply to each plain-text run + * @return the input with {@code transform} applied outside tag spans and tag spans left intact + */ + static String transformOutsideTags(String input, UnaryOperator transform) { + if (input.isEmpty()) { + return input; + } + + StringBuilder output = new StringBuilder(input.length()); + + int segmentStart = 0; + int i = 0; + while (i < input.length()) { + if (input.charAt(i) == '<') { + int tagEnd = findTagEnd(input, i); + + if (tagEnd != -1) { + if (segmentStart < i) { + output.append(transform.apply(input.substring(segmentStart, i))); + } + output.append(input, i, tagEnd + 1); + i = tagEnd + 1; + segmentStart = i; + continue; + } + } + + i++; + } + + if (segmentStart < input.length()) { + output.append(transform.apply(input.substring(segmentStart))); + } + + return output.toString(); + } + + /** + * Finds the index of the {@code >} that closes the tag opened by the {@code <} at {@code start}. + * + *

Quoted sections (single or double quotes) inside the tag are honoured, so a {@code >} that + * appears inside a quoted argument does not prematurely end the tag. An unquoted nested {@code <} + * with no preceding {@code >} means this is not a well-formed tag, and {@code -1} is returned. + * + * @param input the string to scan + * @param start the index of the candidate opening {@code <} + * @return the index of the closing {@code >}, or {@code -1} if no well-formed tag is found + */ + static int findTagEnd(String input, int start) { + char quoteChar = 0; + + for (int i = start + 1; i < input.length(); i++) { + char c = input.charAt(i); + + if (quoteChar != 0) { + // Inside a quoted string - only look for the matching closing quote. + if (c == quoteChar) { + quoteChar = 0; + } + } else if (c == '\'' || c == '"') { + quoteChar = c; + } else if (c == '>') { + return i; + } else if (c == '<' && i != start) { + // Unquoted nested '<' with no prior '>' - not a valid tag, bail out. + return -1; + } + } + + return -1; + } +} diff --git a/src/main/java/gg/gemstone/component/translator/MojangBoxedHexPatternTranslator.java b/src/main/java/gg/gemstone/component/translator/MojangBoxedHexPatternTranslator.java index c4259b7..267c434 100644 --- a/src/main/java/gg/gemstone/component/translator/MojangBoxedHexPatternTranslator.java +++ b/src/main/java/gg/gemstone/component/translator/MojangBoxedHexPatternTranslator.java @@ -18,15 +18,19 @@ package gg.gemstone.component.translator; import java.util.regex.Pattern; -import org.jetbrains.annotations.NotNull; /** * Translates Mojang-style boxed hex color codes into MiniMessage hex tags. * *

The pattern {@code <&#RRGGBB>} is replaced with {@code <#RRGGBB>}. Only exactly * six hex digits are matched; seven-or-more digit sequences are left untouched. + * + *

Unlike the other hex translators this one matches across the whole string rather than only + * outside tag spans: its pattern deliberately targets a {@code <...>} construct, which would + * otherwise be skipped as a tag span. This is safe because {@code <&#RRGGBB>} is never valid + * MiniMessage syntax. */ -class MojangBoxedHexPatternTranslator implements MiniMessageTranslator { +class MojangBoxedHexPatternTranslator extends RegexMiniMessageTranslator { /** * Matches Mojang-style boxed hex codes (e.g. {@code <&#FFFFFF>}). @@ -48,20 +52,13 @@ class MojangBoxedHexPatternTranslator implements MiniMessageTranslator { * Use {@link MiniMessageTranslators#MOJANG_BOXED_HEX}. */ MojangBoxedHexPatternTranslator() { + super(BOXED_MOJANG_PATTERN, BOXED_HEX_REPLACEMENT, BOXED_HEX_ESCAPE_REPLACEMENT); } @Override - public @NotNull String translate(final @NotNull String input) { - return BOXED_MOJANG_PATTERN.matcher(input).replaceAll(BOXED_HEX_REPLACEMENT); - } - - @Override - public @NotNull String escape(final @NotNull String input) { - return BOXED_MOJANG_PATTERN.matcher(input).replaceAll(BOXED_HEX_ESCAPE_REPLACEMENT); - } - - @Override - public @NotNull String strip(final @NotNull String input) { - return BOXED_MOJANG_PATTERN.matcher(input).replaceAll(""); + String replace(String input, String replacement) { + // The pattern targets a `<...>` construct, so it must run over the whole string; the + // default tag-aware matching would skip `<&#RRGGBB>` as a tag span. + return replaceWholeString(input, replacement); } } diff --git a/src/main/java/gg/gemstone/component/translator/MojangUnboxedHexPatternTranslator.java b/src/main/java/gg/gemstone/component/translator/MojangUnboxedHexPatternTranslator.java index 27b656f..dcab6a6 100644 --- a/src/main/java/gg/gemstone/component/translator/MojangUnboxedHexPatternTranslator.java +++ b/src/main/java/gg/gemstone/component/translator/MojangUnboxedHexPatternTranslator.java @@ -18,22 +18,24 @@ package gg.gemstone.component.translator; import java.util.regex.Pattern; -import org.jetbrains.annotations.NotNull; /** * Translates Mojang-style unboxed hex color codes into MiniMessage hex tags. * - *

The pattern {@code &#RRGGBB} is replaced with {@code <#RRGGBB>}. A negative lookbehind - * for {@code <} ensures that already-boxed {@code <&#RRGGBB>} sequences are not matched here - * (those are handled by {@link MojangBoxedHexPatternTranslator}). Only exactly six hex digits - * are matched; seven-or-more digit sequences are left untouched. + *

The pattern {@code &#RRGGBB} is replaced with {@code <#RRGGBB>}. Matching happens only outside + * MiniMessage tag spans (see {@link RegexMiniMessageTranslator}), so an already-boxed + * {@code <&#RRGGBB>} sequence is skipped as a tag span (those are handled by + * {@link MojangBoxedHexPatternTranslator}). Only exactly six hex digits are matched; + * seven-or-more digit sequences are left untouched. */ -class MojangUnboxedHexPatternTranslator implements MiniMessageTranslator { +class MojangUnboxedHexPatternTranslator extends RegexMiniMessageTranslator { /** - * Matches Mojang-style unboxed hex codes (e.g. {@code &#FFFFFF}). + * Matches Mojang-style unboxed hex codes (e.g. {@code &#FFFFFF}). Boxed {@code <&#FFFFFF>} + * sequences are skipped as tag spans by the tag-aware matching in + * {@link RegexMiniMessageTranslator}. */ - private static final Pattern UNBOXED_MOJANG_PATTERN = Pattern.compile("(?}). @@ -50,20 +52,6 @@ class MojangUnboxedHexPatternTranslator implements MiniMessageTranslator { * Use {@link MiniMessageTranslators#MOJANG_UNBOXED_HEX}. */ MojangUnboxedHexPatternTranslator() { - } - - @Override - public @NotNull String translate(final @NotNull String input) { - return UNBOXED_MOJANG_PATTERN.matcher(input).replaceAll(BOXED_HEX_REPLACEMENT); - } - - @Override - public @NotNull String escape(final @NotNull String input) { - return UNBOXED_MOJANG_PATTERN.matcher(input).replaceAll(UNBOXED_HEX_ESCAPE_REPLACEMENT); - } - - @Override - public @NotNull String strip(final @NotNull String input) { - return UNBOXED_MOJANG_PATTERN.matcher(input).replaceAll(""); + super(UNBOXED_MOJANG_PATTERN, BOXED_HEX_REPLACEMENT, UNBOXED_HEX_ESCAPE_REPLACEMENT); } } diff --git a/src/main/java/gg/gemstone/component/translator/RegexMiniMessageTranslator.java b/src/main/java/gg/gemstone/component/translator/RegexMiniMessageTranslator.java new file mode 100644 index 0000000..28bbd93 --- /dev/null +++ b/src/main/java/gg/gemstone/component/translator/RegexMiniMessageTranslator.java @@ -0,0 +1,87 @@ +/* + * Copyright (C) 2026 GemstoneGG/Component Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package gg.gemstone.component.translator; + +import static gg.gemstone.component.translator.MiniMessageTags.transformOutsideTags; + +import java.util.regex.Pattern; +import org.jetbrains.annotations.NotNull; + +/** + * Base class for {@link MiniMessageTranslator}s that recognise a single regex pattern and rewrite, + * neutralise, or remove it. + * + *

By default matching is performed only on the plain-text runs that lie outside + * MiniMessage tag spans (see {@link MiniMessageTags#transformOutsideTags}). This means a pattern + * never has to guard against tokens that legitimately appear inside a tag - for example a hex + * colour argument in {@code } or {@code } - so the pattern + * itself can stay simple. + * + *

Translators whose pattern deliberately targets a {@code <...>} construct (and would therefore + * be skipped as a tag span) override {@link #replace(String, String)} to match the whole string. + */ +abstract class RegexMiniMessageTranslator implements MiniMessageTranslator { + + private final Pattern pattern; + private final String translateReplacement; + private final String escapeReplacement; + + /** + * @param pattern the pattern recognised by this translator + * @param translateReplacement the {@link java.util.regex.Matcher#replaceAll replaceAll} template + * used to convert a match into MiniMessage syntax + * @param escapeReplacement the {@code replaceAll} template used to neutralise a match so a + * subsequent {@code translate} leaves it untouched + */ + RegexMiniMessageTranslator(Pattern pattern, String translateReplacement, String escapeReplacement) { + this.pattern = pattern; + this.translateReplacement = translateReplacement; + this.escapeReplacement = escapeReplacement; + } + + @Override + public @NotNull String translate(@NotNull String input) { + return replace(input, translateReplacement); + } + + @Override + public @NotNull String escape(@NotNull String input) { + return replace(input, escapeReplacement); + } + + @Override + public @NotNull String strip(@NotNull String input) { + return replace(input, ""); + } + + /** + * Replaces every match of {@link #pattern} that lies outside a MiniMessage tag span with + * {@code replacement}. Override to change which parts of the input are considered. + */ + String replace(String input, String replacement) { + return transformOutsideTags(input, segment -> pattern.matcher(segment).replaceAll(replacement)); + } + + /** + * Replaces every match of {@link #pattern} across the whole string, ignoring tag spans. Intended + * for use by subclasses whose pattern itself targets a {@code <...>} construct. + */ + final String replaceWholeString(String input, String replacement) { + return pattern.matcher(input).replaceAll(replacement); + } +} diff --git a/src/main/java/gg/gemstone/component/translator/UnboxedHexPatternTranslator.java b/src/main/java/gg/gemstone/component/translator/UnboxedHexPatternTranslator.java index 1453c99..a6136cf 100644 --- a/src/main/java/gg/gemstone/component/translator/UnboxedHexPatternTranslator.java +++ b/src/main/java/gg/gemstone/component/translator/UnboxedHexPatternTranslator.java @@ -18,26 +18,27 @@ package gg.gemstone.component.translator; import java.util.regex.Pattern; -import org.jetbrains.annotations.NotNull; /** * Translates bare unboxed hex color codes into MiniMessage hex tags. * - *

The pattern {@code #RRGGBB} is replaced with {@code <#RRGGBB>}. Negative lookbehinds for - * {@code <} and {@code &} prevent double-conversion of sequences already handled by - * {@link MojangBoxedHexPatternTranslator} ({@code <&#RRGGBB>}) or - * {@link MojangUnboxedHexPatternTranslator} ({@code &#RRGGBB}). Only exactly six hex digits are + *

The pattern {@code #RRGGBB} is replaced with {@code <#RRGGBB>}. Matching happens only outside + * MiniMessage tag spans (see {@link RegexMiniMessageTranslator}), so hex codes that appear inside a + * tag - whether already-boxed ({@code <#RRGGBB>}) or as a tag argument + * ({@code }, {@code }) - are left untouched. Negative + * lookbehinds for {@code &} and {@code \} additionally skip Mojang-style {@code &#RRGGBB} and + * backslash-escaped {@code \#RRGGBB} sequences in plain text. Only exactly six hex digits are * matched; seven-or-more digit sequences are left untouched. */ -class UnboxedHexPatternTranslator implements MiniMessageTranslator { +class UnboxedHexPatternTranslator extends RegexMiniMessageTranslator { /** - * Matches unboxed hex codes (e.g. {@code #FFFFFF}), excluding those already boxed in - * MiniMessage format (e.g. {@code <#FFFFFF>}), preceded by {@code &} (Mojang-style, - * e.g. {@code &#FFFFFF}), or preceded by a backslash (escape marker, e.g. {@code \#FFFFFF}) - * via a negative lookbehind for {@code <}, {@code &}, and {@code \}. + * Matches unboxed hex codes (e.g. {@code #FFFFFF}) in plain text, excluding those preceded by + * {@code &} (Mojang-style, e.g. {@code &#FFFFFF}) or by a backslash (escape marker, e.g. + * {@code \#FFFFFF}) via a negative lookbehind. Codes inside tag spans (e.g. {@code <#FFFFFF>}) + * are skipped by the tag-aware matching in {@link RegexMiniMessageTranslator}. */ - private static final Pattern UNBOXED_HEX_PATTERN = Pattern.compile("(?}). @@ -54,20 +55,6 @@ class UnboxedHexPatternTranslator implements MiniMessageTranslator { * Use {@link MiniMessageTranslators#UNBOXED_HEX}. */ UnboxedHexPatternTranslator() { - } - - @Override - public @NotNull String translate(final @NotNull String input) { - return UNBOXED_HEX_PATTERN.matcher(input).replaceAll(BOXED_HEX_REPLACEMENT); - } - - @Override - public @NotNull String escape(final @NotNull String input) { - return UNBOXED_HEX_PATTERN.matcher(input).replaceAll(UNBOXED_HEX_ESCAPE_REPLACEMENT); - } - - @Override - public @NotNull String strip(final @NotNull String input) { - return UNBOXED_HEX_PATTERN.matcher(input).replaceAll(""); + super(UNBOXED_HEX_PATTERN, BOXED_HEX_REPLACEMENT, UNBOXED_HEX_ESCAPE_REPLACEMENT); } } diff --git a/src/test/java/gg/gemstone/component/ComponentParserImplTest.java b/src/test/java/gg/gemstone/component/ComponentParserImplTest.java index bc242c4..afae9ff 100644 --- a/src/test/java/gg/gemstone/component/ComponentParserImplTest.java +++ b/src/test/java/gg/gemstone/component/ComponentParserImplTest.java @@ -189,6 +189,27 @@ void sevenDigitHexIsNotPartiallyMatched() { void sevenDigitMojangUnboxedHexIsNotPartiallyMatched() { assertEquals("&#FFFFFFF", parser.translate("&#FFFFFFF")); } + + @Test + void hexArgumentsInsideMiniMessageTagsAreNotConverted() { + // #RRGGBB is valid MiniMessage syntax inside a tag (e.g. gradient stops, ). + // These hex codes must pass through untouched - only bare hex in plain text is converted. + String input = "Heaven " + + "x y"; + assertEquals(input, parser.translate(input)); + } + + @Test + void reportedMiniMessageStringIsLeftIntact() { + // Regression: every #RRGGBB here sits inside a MiniMessage tag, so the full chain must + // be a no-op rather than rewriting the hex args and corrupting the tags. + String input = "HᴇᴀᴠᴇɴCᴜʙᴇ" + + " Sᴜʀᴠɪᴇ ᐠ1" + + " ѕᴇᴍɪ-ʀᴘ" + + " Nouvelle version en préparation !" + + " v2..."; + assertEquals(input, parser.translate(input)); + } } @Nested @@ -293,8 +314,8 @@ void onlyUnboxedHexTranslatorApplied() { MiniMessage.miniMessage() ); - // <&#FFFFFF> and &#FFFFFF are excluded by the negative lookbehind for & and <; - // only the bare #FFFFFF is matched + // &#FFFFFF is excluded by the negative lookbehind for &, and <&#FFFFFF> is skipped as a + // tag span; only the bare #FFFFFF is matched String input = "<&#FFFFFF> &#FFFFFF #FFFFFF"; assertEquals("<&#FFFFFF> &#FFFFFF <#FFFFFF>", parser.translate(input)); } diff --git a/src/test/java/gg/gemstone/component/translator/UnboxedHexPatternTranslatorTest.java b/src/test/java/gg/gemstone/component/translator/UnboxedHexPatternTranslatorTest.java index 3a6587d..7560389 100644 --- a/src/test/java/gg/gemstone/component/translator/UnboxedHexPatternTranslatorTest.java +++ b/src/test/java/gg/gemstone/component/translator/UnboxedHexPatternTranslatorTest.java @@ -77,6 +77,44 @@ void shouldNotCorruptMojangBoxedAfterConversion() { assertEquals("<#FFFFFF>", translator.translate("<#FFFFFF>")); } + @Nested + class InsideTags { + + @Test + void shouldNotConvertHexArgumentInTag() { + // #90630C is a tag argument, not a bare hex code - it must be left untouched. + assertEquals("", translator.translate("")); + } + + @Test + void shouldNotConvertHexInGradientTag() { + assertEquals("", + translator.translate("")); + } + + @Test + void shouldConvertOutsideTagButNotInside() { + assertEquals("<#FFFFFF> ", + translator.translate("#FFFFFF ")); + } + + @Test + void shouldNotEscapeHexInsideTag() { + assertEquals("", translator.escape("")); + } + + @Test + void shouldNotStripHexInsideTag() { + assertEquals("", translator.strip("")); + } + + @Test + void shouldLeaveUnterminatedTagAsText() { + // A '<' with no matching '>' is not a tag span, so the bare hex after it still converts. + assertEquals("", translator.translate("