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));
+ }
+
+}