From 9d73893cc822691d31c91def5e5299a290bb065d Mon Sep 17 00:00:00 2001 From: Sxtanna Date: Mon, 20 Jul 2020 16:59:25 -0400 Subject: [PATCH] replacer api, unit tests, and benchmarks (#354) * added abstracted replacer api, and both char and regex based implementations * added test dependencies for jmh and junit * added unit tests and benchmarks for the replacer implementations * updated replacers to accept specific closure types, added test to verify malformed placeholder handling * updated jmh to 1.23, updated junit to 5.6.2 --- pom.xml | 18 ++ .../replacer/CharsReplacer.java | 129 ++++++++++++ .../replacer/RegexReplacer.java | 55 +++++ .../placeholderapi/replacer/Replacer.java | 33 +++ .../java/me/clip/placeholderapi/Values.java | 188 ++++++++++++++++++ .../replacer/ReplacerBenchmarks.java | 45 +++++ .../replacer/ReplacerUnitTester.java | 67 +++++++ 7 files changed, 535 insertions(+) create mode 100644 src/main/java/me/clip/placeholderapi/replacer/CharsReplacer.java create mode 100644 src/main/java/me/clip/placeholderapi/replacer/RegexReplacer.java create mode 100644 src/main/java/me/clip/placeholderapi/replacer/Replacer.java create mode 100644 src/test/java/me/clip/placeholderapi/Values.java create mode 100644 src/test/java/me/clip/placeholderapi/replacer/ReplacerBenchmarks.java create mode 100644 src/test/java/me/clip/placeholderapi/replacer/ReplacerUnitTester.java diff --git a/pom.xml b/pom.xml index 9ee602b..4bf0934 100644 --- a/pom.xml +++ b/pom.xml @@ -66,6 +66,24 @@ 19.0.0 provided + + org.openjdk.jmh + jmh-core + 1.23 + test + + + org.openjdk.jmh + jmh-generator-annprocess + 1.23 + test + + + org.junit.jupiter + junit-jupiter + 5.6.2 + test + diff --git a/src/main/java/me/clip/placeholderapi/replacer/CharsReplacer.java b/src/main/java/me/clip/placeholderapi/replacer/CharsReplacer.java new file mode 100644 index 0000000..731ca9a --- /dev/null +++ b/src/main/java/me/clip/placeholderapi/replacer/CharsReplacer.java @@ -0,0 +1,129 @@ +package me.clip.placeholderapi.replacer; + +import me.clip.placeholderapi.PlaceholderHook; +import org.bukkit.OfflinePlayer; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.function.Function; + +public final class CharsReplacer implements Replacer +{ + + @NotNull + private final Closure closure; + + public CharsReplacer(@NotNull final Closure closure) + { + this.closure = closure; + } + + + @Override + public @NotNull String apply(@NotNull final String text, @Nullable final OfflinePlayer player, @NotNull final Function lookup) + { + final char[] chars = text.toCharArray(); + final StringBuilder builder = new StringBuilder(text.length()); + + final StringBuilder identifier = new StringBuilder(); + final StringBuilder parameters = new StringBuilder(); + + for (int i = 0; i < chars.length; i++) + { + final char l = chars[i]; + + if (l == '&' && ++i < chars.length) + { + final char c = chars[i]; + + if (c != '0' && c != '1' && c != '2' && c != '3' && c != '4' && c != '5' && c != '6' && c != '7' && c != '8' && c != '9' && c != 'a' && c != 'b' && c != 'c' && c != 'd' && c != 'e' && c != 'f' && c != 'k' && c != 'l' && c != 'm' && c != 'o' && c != 'r' && c != 'x') + { + builder.append(l).append(c); + } + else + { + builder.append('ยง').append(c); + } + continue; + } + + if (l != closure.head || i + 1 >= chars.length) + { + builder.append(l); + continue; + } + + boolean identified = false; + boolean oopsitsbad = false; + + while (++i < chars.length) + { + final char p = chars[i]; + + if (p == closure.tail) + { + break; + } + + if (p == ' ') + { + oopsitsbad = true; + break; + } + + if (p == '_' && !identified) + { + identified = true; + continue; + } + + if (identified) + { + parameters.append(p); + } + else + { + identifier.append(p); + } + } + + final String identifierString = identifier.toString(); + final String parametersString = parameters.toString(); + + identifier.setLength(0); + parameters.setLength(0); + + if (oopsitsbad) + { + builder.append(closure.head).append(identifierString); + + if (identified) + { + builder.append('_').append(parametersString); + } + + builder.append(' '); + continue; + } + + final PlaceholderHook placeholder = lookup.apply(identifierString); + if (placeholder == null) + { + builder.append(closure.head).append(identifierString).append('_').append(parametersString).append(closure.tail); + continue; + } + + final String replacement = placeholder.onRequest(player, parametersString); + if (replacement == null) + { + builder.append(closure.head).append(identifierString).append('_').append(parametersString).append(closure.tail); + continue; + } + + builder.append(replacement); + } + + return builder.toString(); + } + +} diff --git a/src/main/java/me/clip/placeholderapi/replacer/RegexReplacer.java b/src/main/java/me/clip/placeholderapi/replacer/RegexReplacer.java new file mode 100644 index 0000000..8218384 --- /dev/null +++ b/src/main/java/me/clip/placeholderapi/replacer/RegexReplacer.java @@ -0,0 +1,55 @@ +package me.clip.placeholderapi.replacer; + +import me.clip.placeholderapi.PlaceholderHook; +import org.bukkit.ChatColor; +import org.bukkit.OfflinePlayer; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.function.Function; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public final class RegexReplacer implements Replacer +{ + + @NotNull + private final Pattern pattern; + + public RegexReplacer(@NotNull final Closure closure) + { + this.pattern = Pattern.compile(String.format("\\%s((?[a-zA-Z0-9]+)_)(?[^%s%s]+)\\%s", closure.head, closure.head, closure.tail, closure.tail)); + } + + + @Override + public @NotNull String apply(@NotNull final String text, @Nullable final OfflinePlayer player, @NotNull final Function lookup) + { + final Matcher matcher = pattern.matcher(text); + if (!matcher.find()) + { + return text; + } + + final StringBuffer builder = new StringBuffer(); + + do + { + final String identifier = matcher.group("identifier"); + final String parameters = matcher.group("parameters"); + + final PlaceholderHook hook = lookup.apply(identifier); + if (hook == null) + { + continue; + } + + final String requested = hook.onRequest(player, parameters); + matcher.appendReplacement(builder, requested != null ? requested : matcher.group(0)); + } + while (matcher.find()); + + return ChatColor.translateAlternateColorCodes('&', matcher.appendTail(builder).toString()); + } + +} diff --git a/src/main/java/me/clip/placeholderapi/replacer/Replacer.java b/src/main/java/me/clip/placeholderapi/replacer/Replacer.java new file mode 100644 index 0000000..0665442 --- /dev/null +++ b/src/main/java/me/clip/placeholderapi/replacer/Replacer.java @@ -0,0 +1,33 @@ +package me.clip.placeholderapi.replacer; + +import me.clip.placeholderapi.PlaceholderHook; +import org.bukkit.OfflinePlayer; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.function.Function; + +public interface Replacer +{ + + @NotNull + String apply(@NotNull final String text, @Nullable final OfflinePlayer player, @NotNull final Function lookup); + + + enum Closure + { + BRACES('{', '}'), + BRACKETS('[', ']'), + PERCENT('%', '%'); + + + public final char head, tail; + + Closure(final char head, final char tail) + { + this.head = head; + this.tail = tail; + } + } + +} diff --git a/src/test/java/me/clip/placeholderapi/Values.java b/src/test/java/me/clip/placeholderapi/Values.java new file mode 100644 index 0000000..698157e --- /dev/null +++ b/src/test/java/me/clip/placeholderapi/Values.java @@ -0,0 +1,188 @@ +package me.clip.placeholderapi; + +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSet; +import me.clip.placeholderapi.expansion.PlaceholderExpansion; +import me.clip.placeholderapi.replacer.CharsReplacer; +import me.clip.placeholderapi.replacer.RegexReplacer; +import me.clip.placeholderapi.replacer.Replacer; +import org.bukkit.ChatColor; +import org.bukkit.OfflinePlayer; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.Set; +import java.util.function.Function; + +public interface Values +{ + + String SMALL_TEXT = "My name is %player_name%"; + String LARGE_TEXT = "My name is %player_name% and my location is (%player_x%, %player_y%, %player_z%), this placeholder is invalid %server_name%"; + + ImmutableMap PLACEHOLDERS = ImmutableMap.builder() + .put("player", new MockPlayerPlaceholderHook()) + .build(); + + + Replacer CHARS_REPLACER = new CharsReplacer(Replacer.Closure.PERCENT); + Replacer REGEX_REPLACER = new RegexReplacer(Replacer.Closure.PERCENT); + Replacer TESTS_REPLACER = new Replacer() + { + private final Set COLOR_CODES = ImmutableSet.of('0', '1', '2', '3', '4', '5', '6', '7', '8', '9', + 'a', 'b', 'c', 'd', 'e', 'f', 'k', 'l', 'm', 'o', 'r', 'x'); + + private final boolean colorize = true; + private final PlaceholderReplacer.Closure closure = PlaceholderReplacer.Closure.PERCENT; + + @Override + public @NotNull String apply(final @NotNull String text, final @Nullable OfflinePlayer player, final @NotNull Function lookup) + { + char[] chars = text.toCharArray(); + StringBuilder builder = new StringBuilder(chars.length); + + // This won't cause memory leaks. It's inside a method. And we want to use setLength instead of + // creating a new string builder to use the maximum capacity and avoid initializing new objects. + StringBuilder identifier = new StringBuilder(50); + PlaceholderHook handler = null; + + // Stages: + // Stage -1: Look for the color code in the next character. + // Stage 0: No closures detected, or the detected identifier is invalid. We're going forward while appending the characters normally. + // Stage 1: The closure has been detected, looking for the placeholder identifier... + // Stage 2: Detected the identifier and the parameter. Translating the placeholder... + int stage = 0; + + for (char ch : chars) + { + if (stage == -1 && COLOR_CODES.contains(ch)) + { + builder.append(ChatColor.COLOR_CHAR).append(ch); + stage = 0; + continue; + } + + // Check if the placeholder starts or ends. + if (ch == closure.start || ch == closure.end) + { + // If the placeholder ends. + if (stage == 2) + { + String parameter = identifier.toString(); + String translated = handler.onRequest(player, parameter); + + if (translated == null) + { + String name = handler.isExpansion() ? ((PlaceholderExpansion) handler).getIdentifier() : ""; + builder.append(closure.start).append(name).append('_').append(parameter).append(closure.end); + } + else + { + builder.append(translated); + } + + identifier.setLength(0); + stage = 0; + continue; + } + else if (stage == 1) + { // If it just started | Double closures | If it's still hasn't detected the indentifier, reset. + builder.append(closure.start).append(identifier); + } + + identifier.setLength(0); + stage = 1; + continue; + } + + // Placeholder identifier started. + if (stage == 1) + { + // Compare the current character with the idenfitier's. + // We reached the end of our identifier. + if (ch == '_') + { + handler = lookup.apply(identifier.toString()); + if (handler == null) + { + builder.append(closure.start).append(identifier).append('_'); + stage = 0; + } + else + { + identifier.setLength(0); + stage = 2; + } + continue; + } + + // Keep building the identifier name. + identifier.append(ch); + continue; + } + + // Building the placeholder parameter. + if (stage == 2) + { + identifier.append(ch); + continue; + } + + // Nothing placeholder related was found. + if (colorize && ch == '&') + { + stage = -1; + continue; + } + builder.append(ch); + } + + if (identifier != null) + { + if (stage > 0) + { + builder.append(closure.end); + } + builder.append(identifier); + } + return builder.toString(); + } + }; + + + final class MockPlayerPlaceholderHook extends PlaceholderHook + { + + public static final String PLAYER_X = String.valueOf(10); + public static final String PLAYER_Y = String.valueOf(20); + public static final String PLAYER_Z = String.valueOf(30); + public static final String PLAYER_NAME = "Sxtanna"; + + + @Override + public String onRequest(final OfflinePlayer player, final String params) + { + final String[] parts = params.split("_"); + if (parts.length == 0) + { + return null; + } + + switch (parts[0]) + { + case "name": + return PLAYER_NAME; + case "x": + return PLAYER_X; + case "y": + return PLAYER_Y; + case "z": + return PLAYER_Z; + } + + return null; + } + + } + +} diff --git a/src/test/java/me/clip/placeholderapi/replacer/ReplacerBenchmarks.java b/src/test/java/me/clip/placeholderapi/replacer/ReplacerBenchmarks.java new file mode 100644 index 0000000..cf08ae7 --- /dev/null +++ b/src/test/java/me/clip/placeholderapi/replacer/ReplacerBenchmarks.java @@ -0,0 +1,45 @@ +package me.clip.placeholderapi.replacer; + +import me.clip.placeholderapi.Values; +import org.openjdk.jmh.annotations.Benchmark; + +public class ReplacerBenchmarks +{ + + @Benchmark + public void measureCharsReplacerSmallText() + { + Values.CHARS_REPLACER.apply(Values.SMALL_TEXT, null, Values.PLACEHOLDERS::get); + } + + @Benchmark + public void measureRegexReplacerSmallText() + { + Values.REGEX_REPLACER.apply(Values.SMALL_TEXT, null, Values.PLACEHOLDERS::get); + } + + @Benchmark + public void measureCharsReplacerLargeText() + { + Values.CHARS_REPLACER.apply(Values.LARGE_TEXT, null, Values.PLACEHOLDERS::get); + } + + @Benchmark + public void measureRegexReplacerLargeText() + { + Values.REGEX_REPLACER.apply(Values.LARGE_TEXT, null, Values.PLACEHOLDERS::get); + } + + @Benchmark + public void measureTestsReplacerSmallText() + { + Values.TESTS_REPLACER.apply(Values.SMALL_TEXT, null, Values.PLACEHOLDERS::get); + } + + @Benchmark + public void measureTestsReplacerLargeText() + { + Values.TESTS_REPLACER.apply(Values.LARGE_TEXT, null, Values.PLACEHOLDERS::get); + } + +} \ No newline at end of file diff --git a/src/test/java/me/clip/placeholderapi/replacer/ReplacerUnitTester.java b/src/test/java/me/clip/placeholderapi/replacer/ReplacerUnitTester.java new file mode 100644 index 0000000..03d9408 --- /dev/null +++ b/src/test/java/me/clip/placeholderapi/replacer/ReplacerUnitTester.java @@ -0,0 +1,67 @@ +package me.clip.placeholderapi.replacer; + +import me.clip.placeholderapi.Values; +import org.junit.jupiter.api.Test; + +import static me.clip.placeholderapi.Values.MockPlayerPlaceholderHook.PLAYER_NAME; +import static me.clip.placeholderapi.Values.MockPlayerPlaceholderHook.PLAYER_X; +import static me.clip.placeholderapi.Values.MockPlayerPlaceholderHook.PLAYER_Y; +import static me.clip.placeholderapi.Values.MockPlayerPlaceholderHook.PLAYER_Z; +import static org.junit.jupiter.api.Assertions.assertEquals; + +public final class ReplacerUnitTester +{ + + @Test + void testCharsReplacerProducesExpectedSingleValue() + { + assertEquals(PLAYER_NAME, Values.CHARS_REPLACER.apply("%player_name%", null, Values.PLACEHOLDERS::get)); + } + + @Test + void testRegexReplacerProducesExpectedSingleValue() + { + assertEquals(PLAYER_NAME, Values.REGEX_REPLACER.apply("%player_name%", null, Values.PLACEHOLDERS::get)); + } + + @Test + void testCharsReplacerProducesExpectedSentence() + { + assertEquals(String.format("My name is %s and my location is (%s, %s, %s), this placeholder is invalid %%server_name%%", PLAYER_NAME, PLAYER_X, PLAYER_Y, PLAYER_Z), Values.CHARS_REPLACER.apply(Values.LARGE_TEXT, null, Values.PLACEHOLDERS::get)); + } + + @Test + void testRegexReplacerProducesExpectedSentence() + { + assertEquals(String.format("My name is %s and my location is (%s, %s, %s), this placeholder is invalid %%server_name%%", PLAYER_NAME, PLAYER_X, PLAYER_Y, PLAYER_Z), Values.REGEX_REPLACER.apply(Values.LARGE_TEXT, null, Values.PLACEHOLDERS::get)); + } + + @Test + void testResultsAreTheSameAsReplacement() + { + final String resultChars = Values.CHARS_REPLACER.apply("%player_name%", null, Values.PLACEHOLDERS::get); + final String resultRegex = Values.REGEX_REPLACER.apply("%player_name%", null, Values.PLACEHOLDERS::get); + + assertEquals(resultChars, resultRegex); + + assertEquals(PLAYER_NAME, resultChars); + } + + @Test + void testResultsAreTheSameNoReplacement() + { + final String resultChars = Values.CHARS_REPLACER.apply("%player_location%", null, Values.PLACEHOLDERS::get); + final String resultRegex = Values.REGEX_REPLACER.apply("%player_location%", null, Values.PLACEHOLDERS::get); + + assertEquals(resultChars, resultRegex); + } + + @Test + void testCharsReplacerIgnoresMalformed() + { + final String text = "10% and %hello world 15%"; + + assertEquals(text, Values.CHARS_REPLACER.apply(text, null, Values.PLACEHOLDERS::get)); + } + +}