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
18 changes: 18 additions & 0 deletions src/main/java/gg/gemstone/component/ComponentParser.java
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,24 @@ static ComponentParser componentParserAmpersand() {
*/
@NotNull Component parse(@NotNull String input);

/**
* Escapes {@code input} so that no token recognized by this parser's translator chain or by
* {@link MiniMessage} will be interpreted on a subsequent {@link #parse(String)} call.
*
* @param input the raw input string
* @return the escaped string
*/
@NotNull String escape(@NotNull String input);

/**
* Removes every token recognized by this parser's translator chain and by {@link MiniMessage}
* from {@code input}, leaving only plain text.
*
* @param input the raw input string
* @return the stripped string
*/
@NotNull String strip(@NotNull String input);

/**
* Returns a new {@link Builder} pre-populated with this parser's translator chain
* and {@link MiniMessage} instance, allowing a modified copy to be built without
Expand Down
28 changes: 26 additions & 2 deletions src/main/java/gg/gemstone/component/ComponentParserImpl.java
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,35 @@ class ComponentParserImpl implements ComponentParser {

@Override
public @NotNull Component parse(@NotNull String input) {
requireNonNull(input, "input");

return miniMessage.deserialize(translate(input));
}

@Override
public @NotNull String escape(@NotNull String input) {
requireNonNull(input, "input");

String result = input;
for (MiniMessageTranslator translator : translators) {
result = translator.escape(result);
}

return miniMessage.escapeTags(result);
}

@Override
public @NotNull String strip(@NotNull String input) {
requireNonNull(input, "input");

String result = input;
for (MiniMessageTranslator translator : translators) {
result = translator.strip(result);
}

return miniMessage.stripTags(result);
}

@Override
public boolean equals(Object o) {
if (this == o) return true;
Expand All @@ -64,8 +90,6 @@ public ComponentParser.Builder toBuilder() {

@VisibleForTesting
@NotNull String translate(@NotNull String input) {
requireNonNull(input, "input");

String result = input;
for (MiniMessageTranslator translator : translators) {
result = translator.translate(result);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,46 @@ class LegacyFormattingCodeTranslator implements MiniMessageTranslator {
this.sectionChar = sectionChar;
}

@Override
public @NotNull String escape(@NotNull String input) {
return rewriteLegacyCodes(input, true);
}

@Override
public @NotNull String strip(@NotNull String input) {
return rewriteLegacyCodes(input, false);
}

private String rewriteLegacyCodes(String input, boolean escape) {
if (input.isEmpty()) {
return input;
}

StringBuilder output = new StringBuilder(input.length());

int i = 0;
while (i < input.length()) {
char c = input.charAt(i);

if (c == sectionChar && i + 1 < input.length()) {
char code = Character.toLowerCase(input.charAt(i + 1));

if (code == RESET_CODE || COLOR_CODES.containsKey(code) || FORMAT_CODES.containsKey(code)) {
if (escape) {
output.append(sectionChar).append('\\').append(input.charAt(i + 1));
}
i += 2;
continue;
}
}

output.append(c);
i++;
}

return output.toString();
}

@Override
public @NotNull String translate(@NotNull String input) {
if (input.isEmpty()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,25 @@ public interface MiniMessageTranslator {
* @return the translated string, or {@code input} unchanged if no recognized patterns were found
*/
@NotNull String translate(@NotNull String input);

/**
* Escapes every pattern recognized by this translator so it will not be picked up by a
* subsequent {@link #translate(String)} call. The convention is to insert a backslash
* inside the recognized token.
*
* <p>Implementations must not re-escape tokens that have already been escaped by another
* translator earlier in the chain.
*
* @param input the string to escape
* @return the escaped string, or {@code input} unchanged if no recognized patterns were found
*/
@NotNull String escape(@NotNull String input);

/**
* Removes every pattern recognized by this translator from {@code input}.
*
* @param input the string to strip
* @return the stripped string, or {@code input} unchanged if no recognized patterns were found
*/
@NotNull String strip(@NotNull String input);
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@ class MojangBoxedHexPatternTranslator implements MiniMessageTranslator {
*/
private static final String BOXED_HEX_REPLACEMENT = "<#$1>";

/**
* Escape replacement: {@code <&#RRGGBB>} becomes {@code <&\#RRGGBB>}, neutralising the pattern
* for both this translator and {@link MojangUnboxedHexPatternTranslator}.
*/
private static final String BOXED_HEX_ESCAPE_REPLACEMENT = "<&\\\\#$1>";

/**
* Use {@link MiniMessageTranslators#MOJANG_BOXED_HEX}.
*/
Expand All @@ -48,4 +54,14 @@ class MojangBoxedHexPatternTranslator implements MiniMessageTranslator {
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("");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,12 @@ class MojangUnboxedHexPatternTranslator implements MiniMessageTranslator {
*/
private static final String BOXED_HEX_REPLACEMENT = "<#$1>";

/**
* Escape replacement: {@code &#RRGGBB} becomes {@code &\#RRGGBB}, neutralising the pattern
* for this translator.
*/
private static final String UNBOXED_HEX_ESCAPE_REPLACEMENT = "&\\\\#$1";

/**
* Use {@link MiniMessageTranslators#MOJANG_UNBOXED_HEX}.
*/
Expand All @@ -50,4 +56,14 @@ class MojangUnboxedHexPatternTranslator implements MiniMessageTranslator {
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("");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,16 +33,23 @@ class UnboxedHexPatternTranslator implements MiniMessageTranslator {

/**
* Matches unboxed hex codes (e.g. {@code #FFFFFF}), excluding those already boxed in
* MiniMessage format (e.g. {@code <#FFFFFF>}) or preceded by {@code &} (Mojang-style,
* e.g. {@code &#FFFFFF}) via a negative lookbehind for {@code <} and {@code &}.
* 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 \}.
*/
private static final Pattern UNBOXED_HEX_PATTERN = Pattern.compile("(?<![<&])#([A-Fa-f0-9]{6})(?![A-Fa-f0-9])");
private static final Pattern UNBOXED_HEX_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>}).
*/
private static final String BOXED_HEX_REPLACEMENT = "<#$1>";

/**
* Escape replacement: {@code #RRGGBB} becomes {@code #\RRGGBB}, neutralising the pattern
* for this translator.
*/
private static final String UNBOXED_HEX_ESCAPE_REPLACEMENT = "#\\\\$1";

/**
* Use {@link MiniMessageTranslators#UNBOXED_HEX}.
*/
Expand All @@ -53,4 +60,14 @@ class UnboxedHexPatternTranslator implements MiniMessageTranslator {
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("");
}
}
79 changes: 79 additions & 0 deletions src/test/java/gg/gemstone/component/ComponentParserImplTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -332,4 +332,83 @@ void explicitEmptyTranslatorArrayReturnsInputUnchanged() {
assertEquals(input, parser.translate(input));
}
}

@Nested
class Escape {

@Test
void escapeNeutralisesAllTranslatorPatterns() {
// Each translator inserts its own backslash; no translator re-escapes another's output.
// MM.escapeTags leaves the boxed token alone because <&\#AABBCC> is not a known MM tag.
String input = "§cRed <&#AABBCC> &#112233 #445566 done";
assertEquals("§\\cRed <&\\#AABBCC> &\\#112233 #\\445566 done", parser.escape(input));
}

@Test
void escapedOutputIsNotRetranslated() {
// Re-running translate over escaped text must be a no-op for each translator.
String escaped = parser.escape("§cHello <&#AABBCC> &#112233 #445566 done");
assertEquals(escaped, parser.translate(escaped));
}

@Test
void miniMessageTagsAreEscapedByMiniMessage() {
// <red> is not a translator pattern; MM.escapeTags must escape it (both open and close).
assertEquals("\\<red>Hi\\</red>", parser.escape("<red>Hi</red>"));
}

@Test
void emptyStringReturnsEmpty() {
assertEquals("", parser.escape(""));
}

@Test
void plainTextReturnsUnchanged() {
assertEquals("Hello, world!", parser.escape("Hello, world!"));
}
}

@Nested
class Strip {

@Test
void stripRemovesAllTranslatorPatternsAndMmTags() {
// §c, <&#AABBCC>, &#112233, and #445566 are each stripped, leaving their surrounding
// spaces intact - four single-space gaps between "Red" and "done".
assertEquals("Red done",
parser.strip("§cRed <&#AABBCC> &#112233 #445566 done"));
}

@Test
void stripRemovesMiniMessageTags() {
assertEquals("Hi", parser.strip("<red>Hi</red>"));
}

@Test
void stripRemovesMixedLegacyAndMmTags() {
assertEquals("Hello world",
parser.strip("§c<bold>Hello</bold> §rworld"));
}

@Test
void emptyStringReturnsEmpty() {
assertEquals("", parser.strip(""));
}

@Test
void plainTextReturnsUnchanged() {
assertEquals("Hello, world!", parser.strip("Hello, world!"));
}

@Test
void stripWithAmpersandLegacyTranslator() {
ComponentParserImpl parserAmpersand = new ComponentParserImpl(
List.of(MiniMessageTranslators.MOJANG_BOXED_HEX, MiniMessageTranslators.MOJANG_UNBOXED_HEX,
MiniMessageTranslators.UNBOXED_HEX, MiniMessageTranslators.LEGACY_CODE_AMPERSAND),
MiniMessage.miniMessage()
);

assertEquals("Hello world", parserAmpersand.strip("&cHello &rworld"));
}
}
}
Loading