Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -297,28 +299,4 @@ private int flushInnerLegacyTags(Deque<String> 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;
}
}
117 changes: 117 additions & 0 deletions src/main/java/gg/gemstone/component/translator/MiniMessageTags.java
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.
*/

package gg.gemstone.component.translator;

import java.util.function.UnaryOperator;

/**
* Helpers for working with MiniMessage tag spans inside a raw string.
*
* <p>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 <em>inside</em> a tag (e.g. {@code <gradient:#FCD620:#F0A615>} or
* {@code <c:#90630C>}), 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.
*
* <p>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<String> 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}.
*
* <p>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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,19 @@
package gg.gemstone.component.translator;

import java.util.regex.Pattern;
import org.jetbrains.annotations.NotNull;

/**
* Translates Mojang-style <em>boxed</em> hex color codes into MiniMessage hex tags.
*
* <p>The pattern {@code <&#RRGGBB>} is replaced with {@code <#RRGGBB>}. Only exactly
* six hex digits are matched; seven-or-more digit sequences are left untouched.
*
* <p>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>}).
Expand All @@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,22 +18,24 @@
package gg.gemstone.component.translator;

import java.util.regex.Pattern;
import org.jetbrains.annotations.NotNull;

/**
* Translates Mojang-style <em>unboxed</em> hex color codes into MiniMessage hex tags.
*
* <p>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.
* <p>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("(?<!<)&#([A-Fa-f0-9]{6})(?![A-Fa-f0-9])");
private static final Pattern UNBOXED_MOJANG_PATTERN = Pattern.compile("&#([A-Fa-f0-9]{6})(?![A-Fa-f0-9])");

/**
* MiniMessage-style boxed hex code replacement, where {@code $1} is substituted with a 6-digit hex string (e.g. {@code <#FFFFFF>}).
Expand All @@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.
*/

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.
*
* <p>By default matching is performed only on the plain-text runs that lie <em>outside</em>
* 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 <gradient:#FCD620:#F0A615>} or {@code <c:#90630C>} - so the pattern
* itself can stay simple.
*
* <p>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);
}
}
Loading