Skip to content

Commit f0ec806

Browse files
tastybentoclaude
andcommitted
Preserve mid-text spaces in legacy/MiniMessage round-trip
The legacy locale hack of stripping one space after a color code (so auto-translators wouldn't glue "&c" onto "Hello") was being applied unconditionally, including after &r and after mid-text color transitions. This ate inter-segment spaces in round-tripped MiniMessage — e.g. "<gray>Page </gray><yellow>[page]</yellow><gray> of </gray>..." rendered as "Page 1of 4" because componentToLegacy emits "§7Page §e1§7 of §e4" and legacyToMiniMessage then stripped the space after the mid-text §7. Restrict the strip to boundary positions only: start of string, after whitespace, or immediately after another legacy code. Also skip §r in all cases (it's a format terminator). Applied in legacyToMiniMessage, replaceLegacyCodesInline, and stripSpaceAfterColorCodes. BentoBoxWorld/AOneBlock#495 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 40d611d commit f0ec806

2 files changed

Lines changed: 138 additions & 7 deletions

File tree

src/main/java/world/bentobox/bentobox/util/Util.java

Lines changed: 40 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -704,7 +704,14 @@ public static String translateColorCodes(@NonNull String textToColor) {
704704
@NonNull
705705
public static String stripSpaceAfterColorCodes(String textToStrip) {
706706
if (textToStrip == null) return "";
707-
textToStrip = textToStrip.replaceAll("(\u00A7.)[\\s]", "$1");
707+
// The legacy locale hack of writing "&c Hello" (with an intentional space so
708+
// primitive auto-translators wouldn't glue "&c" onto "Hello") only applies when
709+
// the §X code appears at a boundary — start of string, after whitespace, or
710+
// immediately after another §X code. Mid-text codes (e.g. "Page §e1§7 of §e4"
711+
// where §7 is preceded by a digit) must NOT strip the following space, because
712+
// that space is content, not the hack. Also skip §r (reset) in all cases.
713+
// See https://github.com/BentoBoxWorld/AOneBlock/issues/495.
714+
textToStrip = textToStrip.replaceAll("(?<=^|\\s|\u00A7.)(\u00A7[^rR])\\s", "$1");
708715
return textToStrip;
709716
}
710717

@@ -1059,16 +1066,28 @@ public static String legacyToMiniMessage(@NonNull String legacy) {
10591066
StringBuilder result = new StringBuilder();
10601067
List<String> openTags = new ArrayList<>();
10611068
int i = 0;
1069+
// Tracks whether the previous character emitted was part of a legacy code we just
1070+
// consumed. Used for the boundary check on the legacy-space-stripping hack.
1071+
boolean justConsumedCode = false;
10621072
while (i < text.length()) {
10631073
if (i + 1 < text.length() && text.charAt(i) == '&') {
10641074
char code = Character.toLowerCase(text.charAt(i + 1));
10651075
String mmTag = LEGACY_TO_MM_MAP.get(code);
10661076
if (mmTag != null) {
1077+
// Boundary check: the legacy locale hack only applies when the &X code
1078+
// appears at a natural boundary — start of the string, after whitespace,
1079+
// or immediately after another legacy code. Mid-text codes (e.g. "Page
1080+
// §e1§7 of §e4" — the §7 is preceded by "1") must NOT strip the
1081+
// following space, because that space is content, not the locale hack.
1082+
// See https://github.com/BentoBoxWorld/AOneBlock/issues/495.
1083+
boolean atBoundary = i == 0 || justConsumedCode
1084+
|| Character.isWhitespace(text.charAt(i - 1));
10671085
i += 2;
1068-
// Strip space after color code (the locale hack)
1069-
if (i < text.length() && text.charAt(i) == ' ') {
1086+
// &r is a format terminator; any following space is always intentional.
1087+
if (!"reset".equals(mmTag) && atBoundary && i < text.length() && text.charAt(i) == ' ') {
10701088
i++;
10711089
}
1090+
justConsumedCode = true;
10721091
if ("reset".equals(mmTag)) {
10731092
// Close all open tags
10741093
for (int j = openTags.size() - 1; j >= 0; j--) {
@@ -1116,6 +1135,10 @@ public static String legacyToMiniMessage(@NonNull String legacy) {
11161135
if (text.charAt(i) == '<' && text.substring(i).startsWith("<color:#")) {
11171136
int end = text.indexOf('>', i);
11181137
if (end != -1) {
1138+
// Same boundary check as named &X codes — hex was originally &#RRGGBB
1139+
// in the source string, so use the same rule for its space-strip.
1140+
boolean atBoundaryHex = i == 0 || justConsumedCode
1141+
|| Character.isWhitespace(text.charAt(i - 1));
11191142
String colorTag = text.substring(i + 1, end);
11201143
// Close previous color tags, preserving decoration nesting
11211144
List<String> decorationsToReopen = new ArrayList<>();
@@ -1140,14 +1163,17 @@ public static String legacyToMiniMessage(@NonNull String legacy) {
11401163
result.append("<").append(colorTag).append(">");
11411164
openTags.add(colorTag);
11421165
i = end + 1;
1143-
// Strip space after hex color code
1144-
if (i < text.length() && text.charAt(i) == ' ') {
1166+
// Strip space after hex color code only at a boundary.
1167+
// See https://github.com/BentoBoxWorld/AOneBlock/issues/495.
1168+
if (atBoundaryHex && i < text.length() && text.charAt(i) == ' ') {
11451169
i++;
11461170
}
1171+
justConsumedCode = true;
11471172
continue;
11481173
}
11491174
}
11501175
result.append(text.charAt(i));
1176+
justConsumedCode = false;
11511177
i++;
11521178
}
11531179
// Close any remaining open tags
@@ -1200,21 +1226,28 @@ public static String replaceLegacyCodesInline(@NonNull String text) {
12001226
// Replace &X codes with MiniMessage tags (opening only, no closing)
12011227
sb = new StringBuilder();
12021228
int i = 0;
1229+
// See legacyToMiniMessage for the rationale behind the boundary check.
1230+
boolean justConsumedCode = false;
12031231
while (i < text.length()) {
12041232
if (i + 1 < text.length() && text.charAt(i) == '&') {
12051233
char code = Character.toLowerCase(text.charAt(i + 1));
12061234
String mmTag = LEGACY_TO_MM_MAP.get(code);
12071235
if (mmTag != null) {
1236+
boolean atBoundary = i == 0 || justConsumedCode
1237+
|| Character.isWhitespace(text.charAt(i - 1));
12081238
sb.append("<").append(mmTag).append(">");
12091239
i += 2;
1210-
// Strip space after color code (locale hack)
1211-
if (i < text.length() && text.charAt(i) == ' ') {
1240+
// Legacy locale hack — only strip at a boundary, and never after &r.
1241+
// See https://github.com/BentoBoxWorld/AOneBlock/issues/495.
1242+
if (!"reset".equals(mmTag) && atBoundary && i < text.length() && text.charAt(i) == ' ') {
12121243
i++;
12131244
}
1245+
justConsumedCode = true;
12141246
continue;
12151247
}
12161248
}
12171249
sb.append(text.charAt(i));
1250+
justConsumedCode = false;
12181251
i++;
12191252
}
12201253
return sb.toString();

src/test/java/world/bentobox/bentobox/util/LegacyToMiniMessageTest.java

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,4 +187,102 @@ void testBoldCarriesThroughHexColor() {
187187
"Plain text should not contain literal </bold>: " + plainText);
188188
assertEquals("Bold Red", plainText);
189189
}
190+
191+
/**
192+
* Regression for <a href="https://github.com/BentoBoxWorld/AOneBlock/issues/495">
193+
* AOneBlock#495</a>: a MiniMessage locale string with a space between two colored
194+
* segments must retain the space after going through the componentToLegacy →
195+
* legacyToMiniMessage round-trip used by {@code User.sendMessage}.
196+
*/
197+
@Test
198+
void testInterSegmentSpacePreservedAcrossRoundTrip() {
199+
String mm = "<red>Slow down.</red> <green>Click slower.</green>";
200+
Component c = Util.parseMiniMessage(mm);
201+
String legacy = Util.componentToLegacy(c);
202+
203+
// componentToLegacy emits the §r-space-§a pattern for the inter-segment whitespace
204+
assertTrue(legacy.contains("\u00A7r "),
205+
"expected reset-then-space in legacy form, got: " + legacy);
206+
207+
// Round-trip back through legacyToMiniMessage — the space must survive
208+
String mmAgain = Util.legacyToMiniMessage(legacy);
209+
assertFalse(mmAgain.contains("</red><green>"),
210+
"space was eaten between segments: " + mmAgain);
211+
212+
Component c2 = Util.parseMiniMessage(mmAgain);
213+
String plain = PlainTextComponentSerializer.plainText().serialize(c2);
214+
assertEquals("Slow down. Click slower.", plain);
215+
}
216+
217+
/**
218+
* Backwards compat: the legacy locale hack of stripping one space after a
219+
* color/decoration code must still work. Old locale files use {@code &c This is red}
220+
* so primitive auto-translators wouldn't glue the code onto the word.
221+
*/
222+
@Test
223+
void testLegacyLocaleHackStillStripsSpaceAfterColorCode() {
224+
String mm = Util.legacyToMiniMessage("&c This is red");
225+
assertEquals("<red>This is red</red>", mm);
226+
}
227+
228+
/**
229+
* The locale hack must NOT apply to {@code &r}. &amp;r is a format terminator and any
230+
* following space is intentional literal whitespace.
231+
*/
232+
@Test
233+
void testLegacyResetDoesNotStripFollowingSpace() {
234+
String mm = Util.legacyToMiniMessage("&cHello&r world");
235+
String plain = PlainTextComponentSerializer.plainText().serialize(Util.parseMiniMessage(mm));
236+
assertEquals("Hello world", plain);
237+
}
238+
239+
/**
240+
* Regression for <a href="https://github.com/BentoBoxWorld/AOneBlock/issues/495">
241+
* AOneBlock#495</a>: a "Page [page] of [total]" template with alternating colors.
242+
* The round-trip emits {@code §7Page §e1§7 of §e4}. The second {@code §7} is
243+
* preceded by a digit, not by a boundary, so the space after it is content and
244+
* must be preserved.
245+
*/
246+
@Test
247+
void testMidTextColorCodeDoesNotStripContentSpace() {
248+
String mm = "<gray>Page </gray><yellow>1</yellow><gray> of </gray><yellow>4</yellow>";
249+
// Forward: MiniMessage → componentToLegacy
250+
Component c = Util.parseMiniMessage(mm);
251+
String legacy = Util.componentToLegacy(c);
252+
// Round-trip: legacy → Component
253+
Component finalComp = Util.parseMiniMessageOrLegacy(legacy);
254+
String plain = PlainTextComponentSerializer.plainText().serialize(finalComp);
255+
assertEquals("Page 1 of 4", plain);
256+
}
257+
258+
/**
259+
* Same scenario exercised through {@code replaceLegacyCodesInline} (the mixed-content
260+
* path used when MiniMessage templates contain legacy-coded variable substitutions).
261+
*/
262+
@Test
263+
void testMidTextCodeInReplaceLegacyCodesInlinePreservesSpace() {
264+
// Mixed content: MiniMessage tags with legacy codes embedded (e.g. from a variable).
265+
String mixed = "<gray>Page </gray>&e1&7 of &e4";
266+
String result = Util.replaceLegacyCodesInline(mixed);
267+
// The &7 here is preceded by "1" (mid-text), so the space after it must survive.
268+
String plain = PlainTextComponentSerializer.plainText().serialize(Util.parseMiniMessage(result));
269+
assertEquals("Page 1 of 4", plain);
270+
}
271+
272+
/**
273+
* {@code stripSpaceAfterColorCodes} (used by the deprecated {@code translateColorCodes}
274+
* pure-legacy path) must use the same boundary rule.
275+
*/
276+
@Test
277+
@SuppressWarnings("deprecation")
278+
void testStripSpaceAfterColorCodesRespectsBoundary() {
279+
// Boundary cases: strip applies
280+
assertEquals("\u00A7cHello", Util.stripSpaceAfterColorCodes("\u00A7c Hello"));
281+
assertEquals("\u00A7l\u00A7cBold", Util.stripSpaceAfterColorCodes("\u00A7l\u00A7c Bold"));
282+
// Mid-text: must NOT strip
283+
assertEquals("\u00A77Page \u00A7e1\u00A77 of \u00A7e4",
284+
Util.stripSpaceAfterColorCodes("\u00A77Page \u00A7e1\u00A77 of \u00A7e4"));
285+
// Reset must NOT strip
286+
assertEquals("\u00A7r world", Util.stripSpaceAfterColorCodes("\u00A7r world"));
287+
}
190288
}

0 commit comments

Comments
 (0)