From 417989aea1afadc36bb9ac6692ac13ae5fb5b3b3 Mon Sep 17 00:00:00 2001
From: lucko
Date: Thu, 21 May 2026 18:50:01 +0100
Subject: [PATCH] Add common placeholders module (#4267)
---
common/placeholders/build.gradle | 45 ++
.../common/placeholders/Placeholder.java | 117 +++++
.../placeholders/PlaceholderContext.java | 110 +++++
.../placeholders/PlaceholderRegistry.java | 100 ++++
.../placeholders/PlaceholderResolver.java | 106 ++++
.../common/placeholders/Placeholders.java | 461 ++++++++++++++++++
.../placeholders/PlaceholderRegistryTest.java | 88 ++++
.../placeholders/PlaceholderResolverTest.java | 69 +++
.../common/placeholders/PlaceholderTest.java | 56 +++
.../common/placeholders/PlaceholdersTest.java | 105 ++++
.../org.mockito.plugins.MockMaker | 1 +
settings.gradle | 1 +
12 files changed, 1259 insertions(+)
create mode 100644 common/placeholders/build.gradle
create mode 100644 common/placeholders/src/main/java/me/lucko/luckperms/common/placeholders/Placeholder.java
create mode 100644 common/placeholders/src/main/java/me/lucko/luckperms/common/placeholders/PlaceholderContext.java
create mode 100644 common/placeholders/src/main/java/me/lucko/luckperms/common/placeholders/PlaceholderRegistry.java
create mode 100644 common/placeholders/src/main/java/me/lucko/luckperms/common/placeholders/PlaceholderResolver.java
create mode 100644 common/placeholders/src/main/java/me/lucko/luckperms/common/placeholders/Placeholders.java
create mode 100644 common/placeholders/src/test/java/me/lucko/luckperms/common/placeholders/PlaceholderRegistryTest.java
create mode 100644 common/placeholders/src/test/java/me/lucko/luckperms/common/placeholders/PlaceholderResolverTest.java
create mode 100644 common/placeholders/src/test/java/me/lucko/luckperms/common/placeholders/PlaceholderTest.java
create mode 100644 common/placeholders/src/test/java/me/lucko/luckperms/common/placeholders/PlaceholdersTest.java
create mode 100644 common/placeholders/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker
diff --git a/common/placeholders/build.gradle b/common/placeholders/build.gradle
new file mode 100644
index 0000000000..1551982384
--- /dev/null
+++ b/common/placeholders/build.gradle
@@ -0,0 +1,45 @@
+plugins {
+ id("java-library")
+ id("jacoco")
+}
+
+test {
+ useJUnitPlatform()
+}
+
+jacocoTestReport {
+ dependsOn test
+}
+
+dependencies {
+ api project(':api')
+
+ compileOnly 'org.checkerframework:checker-qual:3.49.3'
+ compileOnly 'org.jetbrains:annotations:26.0.2'
+
+ testImplementation 'org.junit.jupiter:junit-jupiter:5.13.0'
+ testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
+
+ testImplementation 'org.testcontainers:testcontainers-junit-jupiter:2.0.2'
+ testImplementation 'org.mockito:mockito-core:5.18.0'
+ testImplementation 'org.mockito:mockito-junit-jupiter:5.18.0'
+}
+
+publishing {
+ //repositories {
+ // maven {
+ // url = 'https://nexus.lucko.me/repository/maven-snapshots/'
+ // credentials {
+ // username = luckoNexusUsername
+ // password = luckoNexusPassword
+ // }
+ // }
+ //}
+ publications {
+ mavenJava(MavenPublication) {
+ from components.java
+ artifactId = 'common-placeholders'
+ version = "${project.ext.fullVersion}-SNAPSHOT"
+ }
+ }
+}
\ No newline at end of file
diff --git a/common/placeholders/src/main/java/me/lucko/luckperms/common/placeholders/Placeholder.java b/common/placeholders/src/main/java/me/lucko/luckperms/common/placeholders/Placeholder.java
new file mode 100644
index 0000000000..dbb7e6fafa
--- /dev/null
+++ b/common/placeholders/src/main/java/me/lucko/luckperms/common/placeholders/Placeholder.java
@@ -0,0 +1,117 @@
+/*
+ * This file is part of LuckPerms, licensed under the MIT License.
+ *
+ * Copyright (c) lucko (Luck)
+ * Copyright (c) contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package me.lucko.luckperms.common.placeholders;
+
+import org.checkerframework.checker.nullness.qual.NonNull;
+import org.jetbrains.annotations.NotNull;
+
+/**
+ * A placeholder definition.
+ */
+public interface Placeholder {
+
+ /**
+ * Get the id of the placeholder.
+ *
+ * @return the id
+ */
+ @NonNull String id();
+
+ /**
+ * A placeholder function that uses the standard {@link PlaceholderContext}
+ */
+ interface BasicPlaceholderFunction {
+ /**
+ * Resolve the value of this placeholder with some given context.
+ *
+ * @param ctx the context
+ * @return the resolved value
+ */
+ @NonNull String resolve(@NonNull PlaceholderContext ctx);
+ }
+
+ /**
+ * A placeholder function that uses the extended {@link PlaceholderContext.WithArgument}
+ */
+ interface UsingArgumentPlaceholderFunction {
+ /**
+ * Resolve the value of this placeholder with some given context.
+ *
+ * @param ctx the context
+ * @return the resolved value
+ */
+ @NonNull String resolve(PlaceholderContext.@NonNull WithArgument ctx);
+ }
+
+ /** A basic placeholder */
+ interface Basic extends Placeholder, BasicPlaceholderFunction {}
+
+ /** A placeholder that uses an argument */
+ interface UsingArgument extends Placeholder, UsingArgumentPlaceholderFunction {}
+
+ /**
+ * Create a standard placeholder using the given resolver function.
+ *
+ * @param id the id
+ * @param resolver the resolver function
+ * @return the placeholder
+ */
+ static Basic basic(@NonNull String id, Placeholder.@NonNull BasicPlaceholderFunction resolver) {
+ return new Basic() {
+ @Override
+ public @NonNull String id() {
+ return id;
+ }
+
+ @Override
+ public @NotNull String resolve(@NotNull PlaceholderContext ctx) {
+ return resolver.resolve(ctx);
+ }
+ };
+ }
+
+ /**
+ * Create a dynamic placeholder using the given resolver function.
+ *
+ * @param id the id
+ * @param resolver the resolver function
+ * @return the placeholder
+ */
+ static UsingArgument usingArgument(@NonNull String id, Placeholder.@NonNull UsingArgumentPlaceholderFunction resolver) {
+ return new UsingArgument() {
+ @Override
+ public @NonNull String id() {
+ return id;
+ }
+
+ @Override
+ public @NotNull String resolve(PlaceholderContext.@NotNull WithArgument ctx) {
+ return resolver.resolve(ctx);
+ }
+ };
+ }
+
+}
diff --git a/common/placeholders/src/main/java/me/lucko/luckperms/common/placeholders/PlaceholderContext.java b/common/placeholders/src/main/java/me/lucko/luckperms/common/placeholders/PlaceholderContext.java
new file mode 100644
index 0000000000..d1b2bd75d7
--- /dev/null
+++ b/common/placeholders/src/main/java/me/lucko/luckperms/common/placeholders/PlaceholderContext.java
@@ -0,0 +1,110 @@
+/*
+ * This file is part of LuckPerms, licensed under the MIT License.
+ *
+ * Copyright (c) lucko (Luck)
+ * Copyright (c) contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package me.lucko.luckperms.common.placeholders;
+
+import net.luckperms.api.LuckPerms;
+import net.luckperms.api.cacheddata.CachedDataManager;
+import net.luckperms.api.cacheddata.CachedMetaData;
+import net.luckperms.api.cacheddata.CachedPermissionData;
+import net.luckperms.api.model.user.User;
+import net.luckperms.api.query.QueryOptions;
+import org.checkerframework.checker.nullness.qual.NonNull;
+
+/**
+ * The context passed to a {@link Placeholder} resolve request.
+ */
+public class PlaceholderContext {
+
+ /** The LuckPerms API instance */
+ private final @NonNull LuckPerms api;
+ /** The user for the player the placeholder is being resolved for */
+ private final @NonNull User user;
+ /** The query options for the player the placeholder is being resolved for */
+ private final @NonNull QueryOptions queryOptions;
+
+ public PlaceholderContext(@NonNull LuckPerms api, @NonNull User user, @NonNull QueryOptions queryOptions) {
+ this.api = api;
+ this.user = user;
+ this.queryOptions = queryOptions;
+ }
+
+ public @NonNull LuckPerms api() {
+ return this.api;
+ }
+
+ public @NonNull User user() {
+ return this.user;
+ }
+
+ public @NonNull QueryOptions queryOptions() {
+ return this.queryOptions;
+ }
+
+ public @NonNull CachedDataManager userData() {
+ return this.user.getCachedData();
+ }
+
+ public @NonNull CachedPermissionData permissionData() {
+ return this.user.getCachedData().getPermissionData(this.queryOptions);
+ }
+
+ public @NonNull CachedMetaData metaData() {
+ return this.user.getCachedData().getMetaData(this.queryOptions);
+ }
+
+ /**
+ * Create a copy of this placeholder context, additionally including an argument.
+ *
+ * @param argument the argument
+ * @return the new context
+ */
+ public WithArgument withArgument(@NonNull String argument) {
+ return new WithArgument(this.api, this.user, this.queryOptions, argument);
+ }
+
+ /**
+ * Extension of {@link PlaceholderContext} with an extra dynamic argument provided by the requester.
+ */
+ public static class WithArgument extends PlaceholderContext {
+
+ /** An additional argument passed to the placeholder resolve request */
+ private final @NonNull String argument;
+
+ public WithArgument(@NonNull LuckPerms api, @NonNull User user, @NonNull QueryOptions queryOptions, @NonNull String argument) {
+ super(api, user, queryOptions);
+ this.argument = argument;
+ }
+
+ /**
+ * Gets the argument.
+ *
+ * @return the argument
+ */
+ public @NonNull String argument() {
+ return this.argument;
+ }
+ }
+}
diff --git a/common/placeholders/src/main/java/me/lucko/luckperms/common/placeholders/PlaceholderRegistry.java b/common/placeholders/src/main/java/me/lucko/luckperms/common/placeholders/PlaceholderRegistry.java
new file mode 100644
index 0000000000..bde5198594
--- /dev/null
+++ b/common/placeholders/src/main/java/me/lucko/luckperms/common/placeholders/PlaceholderRegistry.java
@@ -0,0 +1,100 @@
+/*
+ * This file is part of LuckPerms, licensed under the MIT License.
+ *
+ * Copyright (c) lucko (Luck)
+ * Copyright (c) contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package me.lucko.luckperms.common.placeholders;
+
+import org.checkerframework.checker.nullness.qual.NonNull;
+import org.checkerframework.checker.nullness.qual.Nullable;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+/**
+ * A registry of standard/built-in {@link Placeholder}s.
+ */
+public class PlaceholderRegistry {
+
+ private static final List PLACEHOLDERS = Collections.unmodifiableList(Arrays.asList(
+ Placeholders.PREFIX,
+ Placeholders.SUFFIX,
+ Placeholders.ALL_META,
+ Placeholders.META,
+ Placeholders.PREFIX_ELEMENT,
+ Placeholders.SUFFIX_ELEMENT,
+ Placeholders.ALL_CONTEXT,
+ Placeholders.CONTEXT,
+ Placeholders.GROUPS,
+ Placeholders.INHERITED_GROUPS,
+ Placeholders.PRIMARY_GROUP_NAME,
+ Placeholders.HAS_PERMISSION,
+ Placeholders.INHERITS_PERMISSION,
+ Placeholders.CHECK_PERMISSION,
+ Placeholders.IN_GROUP,
+ Placeholders.INHERITS_GROUP,
+ Placeholders.ON_TRACK,
+ Placeholders.HAS_GROUPS_ON_TRACK,
+ Placeholders.HIGHEST_GROUP_BY_WEIGHT,
+ Placeholders.LOWEST_GROUP_BY_WEIGHT,
+ Placeholders.HIGHEST_INHERITED_GROUP_BY_WEIGHT,
+ Placeholders.LOWEST_INHERITED_GROUP_BY_WEIGHT,
+ Placeholders.HIGHEST_GROUP_WEIGHT,
+ Placeholders.CURRENT_GROUP_ON_TRACK,
+ Placeholders.NEXT_GROUP_ON_TRACK,
+ Placeholders.PREVIOUS_GROUP_ON_TRACK,
+ Placeholders.FIRST_GROUP_ON_TRACKS,
+ Placeholders.LAST_GROUP_ON_TRACKS,
+ Placeholders.EXPIRY_TIME,
+ Placeholders.INHERITED_EXPIRY_TIME,
+ Placeholders.GROUP_EXPIRY_TIME,
+ Placeholders.INHERITED_GROUP_EXPIRY_TIME
+ ));
+
+ private static final Map PLACEHOLDER_MAP = PLACEHOLDERS.stream()
+ .collect(Collectors.toMap(Placeholder::id, Function.identity()));
+
+ /**
+ * Get a list of all placeholders.
+ *
+ * @return a list of placeholders
+ */
+ public static @NonNull List getAll() {
+ return PLACEHOLDERS;
+ }
+
+ /**
+ * Lookup a placeholder by id.
+ *
+ * @param id the id to lookup
+ * @return the placeholder, if found
+ */
+ public static @Nullable Placeholder lookup(@NonNull String id) {
+ return PLACEHOLDER_MAP.get(id);
+ }
+
+}
diff --git a/common/placeholders/src/main/java/me/lucko/luckperms/common/placeholders/PlaceholderResolver.java b/common/placeholders/src/main/java/me/lucko/luckperms/common/placeholders/PlaceholderResolver.java
new file mode 100644
index 0000000000..5eed8ad0ec
--- /dev/null
+++ b/common/placeholders/src/main/java/me/lucko/luckperms/common/placeholders/PlaceholderResolver.java
@@ -0,0 +1,106 @@
+/*
+ * This file is part of LuckPerms, licensed under the MIT License.
+ *
+ * Copyright (c) lucko (Luck)
+ * Copyright (c) contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package me.lucko.luckperms.common.placeholders;
+
+import java.util.Collection;
+import java.util.Locale;
+
+/**
+ * Resolves placeholders using a simple string format:
+ *
+ *
+ *
+ * - placeholder
+ * - placeholder_argument
+ *
+ *
+ *
+ * Note: this resolver does not parse placeholders mid-string, it expects to receive the parsed
+ * placeholder string as input.
+ */
+public class PlaceholderResolver {
+
+ /** The placeholders used by this resolver */
+ private final Collection placeholders;
+
+ /**
+ * Create a resolver using the built-in placeholders registered in {@link PlaceholderRegistry}.
+ */
+ public PlaceholderResolver() {
+ this(PlaceholderRegistry.getAll());
+ }
+
+ /**
+ * Create a resolver using a custom list of placeholders.
+ *
+ * @param placeholders the placeholders
+ */
+ public PlaceholderResolver(Collection placeholders) {
+ this.placeholders = placeholders;
+ }
+
+ /**
+ * Resolve the placeholder value of a given input string
+ *
+ * @param input the input string
+ * @return the resolved value, or null if no placeholder matched
+ */
+ public String resolve(PlaceholderContext ctx, String input) {
+ input = input.toLowerCase(Locale.ROOT);
+ for (Placeholder placeholder : this.placeholders) {
+ String result = attemptResolve(ctx, input, placeholder);
+ if (result != null) {
+ return result;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Attempt to resolve the placeholder value of a given input string for a specific placeholder.
+ *
+ * @param ctx the placeholder context
+ * @param input the input string
+ * @param placeholder the placeholder to attempt to resolve with
+ * @return the resolved value if the placeholder matches the input, or null if it does not match
+ */
+ protected String attemptResolve(PlaceholderContext ctx, String input, Placeholder placeholder) {
+ String id = placeholder.id();
+ if (placeholder instanceof Placeholder.Basic) {
+ if (input.equals(id)) {
+ return ((Placeholder.Basic) placeholder).resolve(ctx);
+ }
+ } else if (placeholder instanceof Placeholder.UsingArgument) {
+ if (input.startsWith(id + "_") && input.length() > (id.length() + 1)) {
+ String argument = input.substring(id.length() + 1);
+ return ((Placeholder.UsingArgument) placeholder).resolve(ctx.withArgument(argument));
+ }
+ } else {
+ throw new IllegalArgumentException("Unknown placeholder type: " + placeholder.getClass());
+ }
+ return null;
+ }
+}
diff --git a/common/placeholders/src/main/java/me/lucko/luckperms/common/placeholders/Placeholders.java b/common/placeholders/src/main/java/me/lucko/luckperms/common/placeholders/Placeholders.java
new file mode 100644
index 0000000000..0979aed198
--- /dev/null
+++ b/common/placeholders/src/main/java/me/lucko/luckperms/common/placeholders/Placeholders.java
@@ -0,0 +1,461 @@
+/*
+ * This file is part of LuckPerms, licensed under the MIT License.
+ *
+ * Copyright (c) lucko (Luck)
+ * Copyright (c) contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package me.lucko.luckperms.common.placeholders;
+
+import net.luckperms.api.metastacking.DuplicateRemovalFunction;
+import net.luckperms.api.metastacking.MetaStackDefinition;
+import net.luckperms.api.metastacking.MetaStackElement;
+import net.luckperms.api.model.group.Group;
+import net.luckperms.api.node.Node;
+import net.luckperms.api.node.NodeType;
+import net.luckperms.api.node.types.InheritanceNode;
+import net.luckperms.api.query.QueryOptions;
+import net.luckperms.api.track.Track;
+import org.jetbrains.annotations.VisibleForTesting;
+
+import java.time.Duration;
+import java.time.temporal.ChronoUnit;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.OptionalInt;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+/**
+ * A class containing the standard, built-in placeholders.
+ */
+public final class Placeholders {
+ private Placeholders() {
+ throw new AssertionError();
+ }
+
+ // Basic meta placeholders
+
+ /** Outputs the user's prefix. */
+ public static final Placeholder.Basic PREFIX = Placeholder.basic("prefix", (ctx) -> stringNullToEmpty(ctx.metaData().getPrefix()));
+
+ /** Outputs the user's suffix. */
+ public static final Placeholder.Basic SUFFIX = Placeholder.basic("suffix", (ctx) -> stringNullToEmpty(ctx.metaData().getSuffix()));
+
+ /** Outputs all meta values for a given key, separated by commas. */
+ public static final Placeholder.UsingArgument ALL_META = Placeholder.usingArgument("all_meta", (ctx) -> {
+ List values = ctx.metaData().getMeta().getOrDefault(ctx.argument(), Collections.emptyList());
+ return values.isEmpty() ? "" : String.join(", ", values);
+ });
+
+ /** Outputs a specific meta value for a given key. */
+ public static final Placeholder.UsingArgument META = Placeholder.usingArgument("meta", (ctx) -> stringNullToEmpty(ctx.metaData().getMetaValue(ctx.argument())));
+
+ // Meta stack element placeholders
+ /** Outputs the user's prefix from a specific meta stack element. */
+ public static final Placeholder.UsingArgument PREFIX_ELEMENT = Placeholder.usingArgument("prefix_element", (ctx) -> {
+ MetaStackElement stackElement = ctx.api().getMetaStackFactory().fromString(ctx.argument()).orElse(null);
+ if (stackElement == null) {
+ throw new IllegalArgumentException("Invalid meta stack element: " + ctx.argument());
+ }
+
+ MetaStackDefinition stackDefinition = ctx.api().getMetaStackFactory().createDefinition(
+ Collections.singletonList(stackElement), DuplicateRemovalFunction.RETAIN_ALL, "", "", "");
+ QueryOptions newOptions = ctx.queryOptions().toBuilder()
+ .option(MetaStackDefinition.PREFIX_STACK_KEY, stackDefinition)
+ .option(MetaStackDefinition.SUFFIX_STACK_KEY, stackDefinition)
+ .build();
+
+ return stringNullToEmpty(ctx.userData().getMetaData(newOptions).getPrefix());
+ });
+
+ /** Outputs the user's suffix from a specific meta stack element. */
+ public static final Placeholder.UsingArgument SUFFIX_ELEMENT = Placeholder.usingArgument("suffix_element", (ctx) -> {
+ MetaStackElement stackElement = ctx.api().getMetaStackFactory().fromString(ctx.argument()).orElse(null);
+ if (stackElement == null) {
+ throw new IllegalArgumentException("Invalid meta stack element: " + ctx.argument());
+ }
+
+ MetaStackDefinition stackDefinition = ctx.api().getMetaStackFactory().createDefinition(
+ Collections.singletonList(stackElement), DuplicateRemovalFunction.RETAIN_ALL, "", "", "");
+ QueryOptions newOptions = ctx.queryOptions().toBuilder()
+ .option(MetaStackDefinition.PREFIX_STACK_KEY, stackDefinition)
+ .option(MetaStackDefinition.SUFFIX_STACK_KEY, stackDefinition)
+ .build();
+
+ return Objects.toString(ctx.userData().getMetaData(newOptions).getSuffix(), "");
+ });
+
+ // Context placeholders
+
+ /** Outputs all context key-value pairs, separated by commas. */
+ public static final Placeholder.Basic ALL_CONTEXT = Placeholder.basic("all_context", (ctx) ->
+ ctx.queryOptions().context().toSet().stream()
+ .map(c -> c.getKey() + "=" + c.getValue())
+ .collect(Collectors.joining(", "))
+ );
+
+ /** Outputs all values for a specific context key, separated by commas. */
+ public static final Placeholder.UsingArgument CONTEXT = Placeholder.usingArgument("context", (ctx) -> String.join(", ", ctx.queryOptions().context().getValues(ctx.argument())));
+
+ // Group placeholders
+
+ /** Outputs the user's directly assigned groups, separated by commas. */
+ public static final Placeholder.Basic GROUPS = Placeholder.basic("groups", (ctx) ->
+ ctx.user().getNodes(NodeType.INHERITANCE).stream()
+ .filter(n -> ctx.queryOptions().satisfies(n.getContexts()))
+ .map(InheritanceNode::getGroupName)
+ .map(name -> convertGroupDisplayName(ctx, name))
+ .collect(Collectors.joining(", "))
+ );
+
+ /** Outputs all groups the user inherits from, separated by commas. */
+ public static final Placeholder.Basic INHERITED_GROUPS = Placeholder.basic("inherited_groups", (ctx) ->
+ ctx.user().getInheritedGroups(ctx.queryOptions()).stream()
+ .map(Group::getFriendlyName)
+ .collect(Collectors.joining(", "))
+ );
+
+ /** Outputs the user's primary group name. */
+ public static final Placeholder.Basic PRIMARY_GROUP_NAME = Placeholder.basic("primary_group_name", (ctx) ->
+ convertGroupDisplayName(ctx, ctx.user().getPrimaryGroup())
+ );
+
+ // Permission check placeholders
+ /** Checks if the user has a specific permission node directly assigned (outputs "true" or "false"). */
+ public static final Placeholder.UsingArgument HAS_PERMISSION = Placeholder.usingArgument("has_permission", (ctx) ->
+ String.valueOf(ctx.user().getNodes().stream()
+ .filter(n -> ctx.queryOptions().satisfies(n.getContexts()))
+ .anyMatch(n -> n.getKey().equals(ctx.argument())))
+ );
+
+ /** Checks if the user inherits a specific permission node (outputs "true" or "false"). */
+ public static final Placeholder.UsingArgument INHERITS_PERMISSION = Placeholder.usingArgument("inherits_permission", (ctx) ->
+ String.valueOf(ctx.user().resolveInheritedNodes(ctx.queryOptions()).stream()
+ .filter(n -> n.getContexts().isSatisfiedBy(ctx.queryOptions().context()))
+ .anyMatch(n -> n.getKey().equals(ctx.argument())))
+ );
+
+ /** Checks the result of a permission check (outputs "true" or "false"). */
+ public static final Placeholder.UsingArgument CHECK_PERMISSION = Placeholder.usingArgument("check_permission", (ctx) ->
+ String.valueOf(ctx.permissionData().checkPermission(ctx.argument()).asBoolean())
+ );
+
+ /** Checks if the user is directly in a specific group (outputs "true" or "false"). */
+ public static final Placeholder.UsingArgument IN_GROUP = Placeholder.usingArgument("in_group", (ctx) ->
+ String.valueOf(ctx.user().getNodes(NodeType.INHERITANCE).stream()
+ .filter(n -> ctx.queryOptions().satisfies(n.getContexts()))
+ .map(InheritanceNode::getGroupName)
+ .anyMatch(s -> s.equalsIgnoreCase(ctx.argument())))
+ );
+
+ /** Checks if the user inherits from a specific group (outputs "true" or "false"). */
+ public static final Placeholder.UsingArgument INHERITS_GROUP = Placeholder.usingArgument("inherits_group", (ctx) ->
+ String.valueOf(ctx.user().getInheritedGroups(ctx.queryOptions()).stream()
+ .anyMatch(g -> g.getName().equalsIgnoreCase(ctx.argument())))
+ );
+
+ // Track placeholders
+ /** Checks if the user's primary group is on a specific track (outputs "true" or "false"). */
+ public static final Placeholder.UsingArgument ON_TRACK = Placeholder.usingArgument("on_track", (ctx) ->
+ String.valueOf(Optional.ofNullable(ctx.api().getTrackManager().getTrack(ctx.argument()))
+ .map(t -> t.containsGroup(ctx.user().getPrimaryGroup()))
+ .orElse(false))
+ );
+
+ /** Checks if the user has any groups on a specific track (outputs "true" or "false"). */
+ public static final Placeholder.UsingArgument HAS_GROUPS_ON_TRACK = Placeholder.usingArgument("has_groups_on_track", (ctx) ->
+ String.valueOf(Optional.ofNullable(ctx.api().getTrackManager().getTrack(ctx.argument()))
+ .map(t -> ctx.user().getNodes(NodeType.INHERITANCE).stream()
+ .map(InheritanceNode::getGroupName)
+ .anyMatch(t::containsGroup)
+ )
+ .orElse(false))
+ );
+
+ // Group weight placeholders
+
+ /** Outputs the name of the user's highest weighted directly assigned group. */
+ public static final Placeholder.Basic HIGHEST_GROUP_BY_WEIGHT = Placeholder.basic("highest_group_by_weight", (ctx) ->
+ ctx.user().getNodes(NodeType.INHERITANCE).stream()
+ .filter(n -> ctx.queryOptions().satisfies(n.getContexts()))
+ .map(InheritanceNode::getGroupName)
+ .map(n -> ctx.api().getGroupManager().getGroup(n))
+ .filter(Objects::nonNull)
+ .max(Comparator.comparingInt(g -> g.getWeight().orElse(0)))
+ .map(Group::getName)
+ .map(name -> convertGroupDisplayName(ctx, name))
+ .orElse("")
+ );
+
+ /** Outputs the name of the user's lowest weighted directly assigned group. */
+ public static final Placeholder.Basic LOWEST_GROUP_BY_WEIGHT = Placeholder.basic("lowest_group_by_weight", (ctx) ->
+ ctx.user().getNodes(NodeType.INHERITANCE).stream()
+ .filter(n -> ctx.queryOptions().satisfies(n.getContexts()))
+ .map(InheritanceNode::getGroupName)
+ .map(n -> ctx.api().getGroupManager().getGroup(n))
+ .filter(Objects::nonNull)
+ .min(Comparator.comparingInt(g -> g.getWeight().orElse(0)))
+ .map(Group::getName)
+ .map(name -> convertGroupDisplayName(ctx, name))
+ .orElse("")
+ );
+
+ /** Outputs the name of the user's highest weighted inherited group. */
+ public static final Placeholder.Basic HIGHEST_INHERITED_GROUP_BY_WEIGHT = Placeholder.basic("highest_inherited_group_by_weight", (ctx) ->
+ ctx.user().getInheritedGroups(ctx.queryOptions()).stream()
+ .max(Comparator.comparingInt(g -> g.getWeight().orElse(0)))
+ .map(Group::getName)
+ .map(name -> convertGroupDisplayName(ctx, name))
+ .orElse("")
+ );
+
+ /** Outputs the name of the user's lowest weighted inherited group. */
+ public static final Placeholder.Basic LOWEST_INHERITED_GROUP_BY_WEIGHT = Placeholder.basic("lowest_inherited_group_by_weight", (ctx) ->
+ ctx.user().getInheritedGroups(ctx.queryOptions()).stream()
+ .min(Comparator.comparingInt(g -> g.getWeight().orElse(0)))
+ .map(Group::getName)
+ .map(name -> convertGroupDisplayName(ctx, name))
+ .orElse("")
+ );
+
+ /** Outputs the weight value of the user's highest weighted directly assigned group. */
+ public static final Placeholder.Basic HIGHEST_GROUP_WEIGHT = Placeholder.basic("highest_group_weight", (ctx) ->
+ String.valueOf(ctx.user().getNodes(NodeType.INHERITANCE).stream()
+ .filter(n -> ctx.queryOptions().satisfies(n.getContexts()))
+ .map(InheritanceNode::getGroupName)
+ .map(n -> ctx.api().getGroupManager().getGroup(n))
+ .filter(Objects::nonNull)
+ .map(Group::getWeight)
+ .filter(OptionalInt::isPresent)
+ .mapToInt(OptionalInt::getAsInt)
+ .max()
+ .orElse(0))
+ );
+
+ // Track position placeholders
+
+ /** Outputs the user's current group on a specific track. */
+ public static final Placeholder.UsingArgument CURRENT_GROUP_ON_TRACK = Placeholder.usingArgument("current_group_on_track", (ctx) -> {
+ Track track = ctx.api().getTrackManager().getTrack(ctx.argument());
+ if (track == null) {
+ return "";
+ }
+
+ List groups = ctx.user().getNodes(NodeType.INHERITANCE).stream()
+ .filter(n -> track.containsGroup(n.getGroupName()))
+ .filter(n -> ctx.queryOptions().satisfies(n.getContexts()))
+ .distinct()
+ .map(n -> ctx.api().getGroupManager().getGroup(n.getGroupName()))
+ .collect(Collectors.toList());
+
+ if (groups.size() != 1) {
+ return "";
+ }
+
+ return groups.get(0).getFriendlyName();
+ });
+
+ /** Outputs the next group on a specific track. */
+ public static final Placeholder.UsingArgument NEXT_GROUP_ON_TRACK = Placeholder.usingArgument("next_group_on_track", (ctx) -> {
+ Track track = ctx.api().getTrackManager().getTrack(ctx.argument());
+ if (track == null || track.getGroups().size() <= 1) {
+ return "";
+ }
+
+ List groups = ctx.user().getNodes(NodeType.INHERITANCE).stream()
+ .filter(n -> track.containsGroup(n.getGroupName()))
+ .filter(n -> ctx.queryOptions().satisfies(n.getContexts()))
+ .distinct()
+ .map(n -> ctx.api().getGroupManager().getGroup(n.getGroupName()))
+ .collect(Collectors.toList());
+
+ if (groups.size() != 1) {
+ return "";
+ }
+
+ return Objects.toString(convertGroupDisplayName(ctx, track.getNext(groups.get(0))), "");
+ });
+
+ /** Outputs the previous group on a specific track. */
+ public static final Placeholder.UsingArgument PREVIOUS_GROUP_ON_TRACK = Placeholder.usingArgument("previous_group_on_track", (ctx) -> {
+ Track track = ctx.api().getTrackManager().getTrack(ctx.argument());
+ if (track == null || track.getGroups().size() <= 1) {
+ return "";
+ }
+
+ List groups = ctx.user().getNodes(NodeType.INHERITANCE).stream()
+ .filter(n -> track.containsGroup(n.getGroupName()))
+ .filter(n -> ctx.queryOptions().satisfies(n.getContexts()))
+ .distinct()
+ .map(n -> ctx.api().getGroupManager().getGroup(n.getGroupName()))
+ .collect(Collectors.toList());
+
+ if (groups.size() != 1) {
+ return "";
+ }
+
+ return Objects.toString(convertGroupDisplayName(ctx, track.getPrevious(groups.get(0))), "");
+ });
+
+ /** Outputs the first group the user has on a comma-separated list of tracks. */
+ public static final Placeholder.UsingArgument FIRST_GROUP_ON_TRACKS = Placeholder.usingArgument("first_group_on_tracks", (ctx) -> {
+ List tracks = Arrays.stream(ctx.argument().split(",")).map(String::trim).collect(Collectors.toList());
+ Set groups = ctx.user().getInheritedGroups(ctx.queryOptions()).stream().map(Group::getName).collect(Collectors.toSet());
+
+ return tracks.stream()
+ .map(n -> ctx.api().getTrackManager().getTrack(n))
+ .filter(Objects::nonNull)
+ .map(Track::getGroups)
+ .map(trackGroups -> trackGroups.stream().filter(groups::contains).findFirst())
+ .filter(Optional::isPresent)
+ .map(Optional::get)
+ .findFirst()
+ .map(name -> convertGroupDisplayName(ctx, name))
+ .orElse("");
+ });
+
+ /** Outputs the last group the user has on a comma-separated list of tracks. */
+ public static final Placeholder.UsingArgument LAST_GROUP_ON_TRACKS = Placeholder.usingArgument("last_group_on_tracks", (ctx) -> {
+ List tracks = Arrays.stream(ctx.argument().split(",")).map(String::trim).collect(Collectors.toList());
+ Set groups = ctx.user().getInheritedGroups(ctx.queryOptions()).stream().map(Group::getName).collect(Collectors.toSet());
+
+ return tracks.stream()
+ .map(n -> ctx.api().getTrackManager().getTrack(n))
+ .filter(Objects::nonNull)
+ .map(Track::getGroups)
+ .map(list -> {
+ List copy = new ArrayList<>(list);
+ Collections.reverse(copy);
+ return copy;
+ })
+ .map(trackGroups -> trackGroups.stream().filter(groups::contains).findFirst())
+ .filter(Optional::isPresent)
+ .map(Optional::get)
+ .findFirst()
+ .map(name -> convertGroupDisplayName(ctx, name))
+ .orElse("");
+ });
+
+ // Expiry time placeholders
+
+ /** Outputs the expiry time remaining for a specific permission node. */
+ public static final Placeholder.UsingArgument EXPIRY_TIME = Placeholder.usingArgument("expiry_time", (ctx) ->
+ ctx.user().getNodes().stream()
+ .filter(Node::hasExpiry)
+ .filter(n -> n.getKey().equals(ctx.argument()))
+ .filter(n -> ctx.queryOptions().satisfies(n.getContexts()))
+ .map(Node::getExpiryDuration)
+ .filter(Objects::nonNull)
+ .filter(d -> !d.isNegative())
+ .findFirst()
+ .map(Placeholders::formatDuration)
+ .orElse("")
+ );
+
+ /** Outputs the expiry time remaining for a specific inherited permission node. */
+ public static final Placeholder.UsingArgument INHERITED_EXPIRY_TIME = Placeholder.usingArgument("inherited_expiry_time", (ctx) ->
+ ctx.user().resolveInheritedNodes(ctx.queryOptions()).stream()
+ .filter(Node::hasExpiry)
+ .filter(n -> n.getKey().equals(ctx.argument()))
+ .map(Node::getExpiryDuration)
+ .filter(Objects::nonNull)
+ .filter(d -> !d.isNegative())
+ .findFirst()
+ .map(Placeholders::formatDuration)
+ .orElse("")
+ );
+
+ /** Outputs the expiry time remaining for a specific group membership. */
+ public static final Placeholder.UsingArgument GROUP_EXPIRY_TIME = Placeholder.usingArgument("group_expiry_time", (ctx) ->
+ ctx.user().getNodes(NodeType.INHERITANCE).stream()
+ .filter(Node::hasExpiry)
+ .filter(n -> n.getGroupName().equals(ctx.argument()))
+ .filter(n -> ctx.queryOptions().satisfies(n.getContexts()))
+ .map(Node::getExpiryDuration)
+ .filter(Objects::nonNull)
+ .filter(d -> !d.isNegative())
+ .findFirst()
+ .map(Placeholders::formatDuration)
+ .orElse("")
+ );
+
+ /** Outputs the expiry time remaining for a specific inherited group membership. */
+ public static final Placeholder.UsingArgument INHERITED_GROUP_EXPIRY_TIME = Placeholder.usingArgument("inherited_group_expiry_time", (ctx) ->
+ ctx.user().resolveInheritedNodes(ctx.queryOptions()).stream()
+ .filter(Node::hasExpiry)
+ .filter(NodeType.INHERITANCE::matches)
+ .map(NodeType.INHERITANCE::cast)
+ .filter(n -> n.getGroupName().equals(ctx.argument()))
+ .map(Node::getExpiryDuration)
+ .filter(Objects::nonNull)
+ .filter(d -> !d.isNegative())
+ .findFirst()
+ .map(Placeholders::formatDuration)
+ .orElse("")
+ );
+
+ private static String stringNullToEmpty(String string) {
+ return string == null ? "" : string;
+ }
+
+ private static String convertGroupDisplayName(PlaceholderContext ctx, String groupName) {
+ Group group = ctx.api().getGroupManager().getGroup(groupName);
+ return group != null ? group.getFriendlyName() : groupName;
+ }
+
+ // simple version of me.lucko.luckperms.common.util.DurationFormatter
+ @VisibleForTesting
+ static String formatDuration(Duration duration) {
+ if (duration == null || duration.isNegative()) {
+ return "";
+ }
+
+ long seconds = duration.getSeconds();
+ StringBuilder builder = new StringBuilder();
+
+ ChronoUnit[] units = {ChronoUnit.YEARS, ChronoUnit.MONTHS, ChronoUnit.WEEKS,
+ ChronoUnit.DAYS, ChronoUnit.HOURS, ChronoUnit.MINUTES, ChronoUnit.SECONDS};
+ String[] labels = {"y", "mo", "w", "d", "h", "m", "s"};
+
+ for (int i = 0; i < units.length; i++) {
+ long unitSeconds = units[i].getDuration().getSeconds();
+ long n = seconds / unitSeconds;
+ if (n > 0) {
+ seconds -= unitSeconds * n;
+ if (builder.length() > 0) {
+ builder.append(" ");
+ }
+ builder.append(n).append(labels[i]);
+ }
+ if (seconds <= 0) {
+ break;
+ }
+ }
+
+ return builder.length() == 0 ? "0s" : builder.toString();
+ }
+}
diff --git a/common/placeholders/src/test/java/me/lucko/luckperms/common/placeholders/PlaceholderRegistryTest.java b/common/placeholders/src/test/java/me/lucko/luckperms/common/placeholders/PlaceholderRegistryTest.java
new file mode 100644
index 0000000000..acce7a7324
--- /dev/null
+++ b/common/placeholders/src/test/java/me/lucko/luckperms/common/placeholders/PlaceholderRegistryTest.java
@@ -0,0 +1,88 @@
+/*
+ * This file is part of LuckPerms, licensed under the MIT License.
+ *
+ * Copyright (c) lucko (Luck)
+ * Copyright (c) contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package me.lucko.luckperms.common.placeholders;
+
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Field;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+
+public class PlaceholderRegistryTest {
+
+ @Test
+ public void testAllPlaceholdersAreRegistered() {
+ List returnedByAllMethod = PlaceholderRegistry.getAll();
+ Map inClass = Arrays.stream(Placeholders.class.getDeclaredFields())
+ .filter(f -> Placeholder.class.isAssignableFrom(f.getType()))
+ .collect(Collectors.toMap(Field::getName, f -> {
+ try {
+ return (Placeholder) f.get(null);
+ } catch (IllegalAccessException e) {
+ throw new RuntimeException(e);
+ }
+ }, (a, b) -> { throw new UnsupportedOperationException(); }, LinkedHashMap::new));
+
+ assertEquals(new ArrayList<>(inClass.values()), returnedByAllMethod);
+ inClass.forEach((fieldName, placeholder) ->
+ assertEquals(fieldName.toLowerCase(Locale.ROOT), placeholder.id(), "Placeholder " + fieldName + " has an id that doesn't match its field name")
+ );
+ }
+
+ @Test
+ public void testPlaceholdersDontOverlap() {
+ for (Placeholder placeholder : PlaceholderRegistry.getAll()) {
+ for (Placeholder other : PlaceholderRegistry.getAll()) {
+ if (placeholder == other) {
+ continue;
+ }
+
+ assertNotEquals(placeholder.id(), other.id(), "Placeholder " + placeholder + " has the same id as " + other);
+ assertFalse(other instanceof Placeholder.UsingArgument && placeholder.id().startsWith(other.id()), "Placeholder " + placeholder.id() + " has an id that overlaps with " + other.id());
+ }
+ }
+ }
+
+ @Test
+ public void testRegistryLookup() {
+ Placeholder value = PlaceholderRegistry.lookup("prefix_element");
+ assertSame(Placeholders.PREFIX_ELEMENT, value);
+
+ assertNull(PlaceholderRegistry.lookup("non_existent"));
+ }
+
+}
diff --git a/common/placeholders/src/test/java/me/lucko/luckperms/common/placeholders/PlaceholderResolverTest.java b/common/placeholders/src/test/java/me/lucko/luckperms/common/placeholders/PlaceholderResolverTest.java
new file mode 100644
index 0000000000..cdfe998a4e
--- /dev/null
+++ b/common/placeholders/src/test/java/me/lucko/luckperms/common/placeholders/PlaceholderResolverTest.java
@@ -0,0 +1,69 @@
+/*
+ * This file is part of LuckPerms, licensed under the MIT License.
+ *
+ * Copyright (c) lucko (Luck)
+ * Copyright (c) contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package me.lucko.luckperms.common.placeholders;
+
+import net.luckperms.api.LuckPerms;
+import net.luckperms.api.model.user.User;
+import net.luckperms.api.query.QueryOptions;
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.mockito.Mockito.mock;
+
+public class PlaceholderResolverTest {
+
+ private final List placeholders = Arrays.asList(
+ Placeholder.basic("test_simple", ctx -> "hello world"),
+ Placeholder.usingArgument("test_arg", ctx -> "hello " + ctx.argument())
+ );
+ private final PlaceholderContext ctx = new PlaceholderContext(mock(LuckPerms.class), mock(User.class), mock(QueryOptions.class));
+ private final PlaceholderResolver resolver = new PlaceholderResolver(this.placeholders);
+
+ @Test
+ public void testNullResolve() {
+ assertNull(this.resolver.resolve(this.ctx, "non_existent"));
+ assertNull(this.resolver.resolve(this.ctx, ""));
+
+ assertNull(this.resolver.resolve(this.ctx, "test_simple_hello")); // reject basic with arg provided
+ assertNull(this.resolver.resolve(this.ctx, "test_arg")); // reject usingArgument without arg provided
+ assertNull(this.resolver.resolve(this.ctx, "test_arg_")); // reject usingArgument without arg provided
+ }
+
+ @Test
+ public void testBasicResolve() {
+ assertEquals("hello world", this.resolver.resolve(this.ctx, "test_simple"));
+ }
+
+ @Test
+ public void testUsingArgumentResolve() {
+ assertEquals("hello there", this.resolver.resolve(this.ctx, "test_arg_there"));
+ }
+
+}
diff --git a/common/placeholders/src/test/java/me/lucko/luckperms/common/placeholders/PlaceholderTest.java b/common/placeholders/src/test/java/me/lucko/luckperms/common/placeholders/PlaceholderTest.java
new file mode 100644
index 0000000000..362e492f8e
--- /dev/null
+++ b/common/placeholders/src/test/java/me/lucko/luckperms/common/placeholders/PlaceholderTest.java
@@ -0,0 +1,56 @@
+/*
+ * This file is part of LuckPerms, licensed under the MIT License.
+ *
+ * Copyright (c) lucko (Luck)
+ * Copyright (c) contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package me.lucko.luckperms.common.placeholders;
+
+import net.luckperms.api.LuckPerms;
+import net.luckperms.api.model.user.User;
+import net.luckperms.api.query.QueryOptions;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.Mockito.mock;
+
+public class PlaceholderTest {
+
+ private final PlaceholderContext ctx = new PlaceholderContext(mock(LuckPerms.class), mock(User.class), mock(QueryOptions.class));
+
+ @Test
+ public void testBasic() {
+ Placeholder.Basic placeholder = Placeholder.basic("test", ctx -> "hello");
+
+ assertEquals("test", placeholder.id());
+ assertEquals("hello", placeholder.resolve(this.ctx));
+ }
+
+ @Test
+ public void testUsingArgument() {
+ Placeholder.UsingArgument placeholder = Placeholder.usingArgument("test", ctx -> "hello " + ctx.argument());
+
+ assertEquals("test", placeholder.id());
+ assertEquals("hello world", placeholder.resolve(this.ctx.withArgument("world")));
+ }
+
+}
diff --git a/common/placeholders/src/test/java/me/lucko/luckperms/common/placeholders/PlaceholdersTest.java b/common/placeholders/src/test/java/me/lucko/luckperms/common/placeholders/PlaceholdersTest.java
new file mode 100644
index 0000000000..d1ca22a1bd
--- /dev/null
+++ b/common/placeholders/src/test/java/me/lucko/luckperms/common/placeholders/PlaceholdersTest.java
@@ -0,0 +1,105 @@
+/*
+ * This file is part of LuckPerms, licensed under the MIT License.
+ *
+ * Copyright (c) lucko (Luck)
+ * Copyright (c) contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package me.lucko.luckperms.common.placeholders;
+
+import net.luckperms.api.LuckPerms;
+import net.luckperms.api.cacheddata.CachedDataManager;
+import net.luckperms.api.cacheddata.CachedMetaData;
+import net.luckperms.api.cacheddata.CachedPermissionData;
+import net.luckperms.api.model.user.User;
+import net.luckperms.api.query.QueryOptions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.time.Duration;
+import java.time.temporal.ChronoUnit;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.Mockito.lenient;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+public class PlaceholdersTest {
+
+ @Mock private LuckPerms api;
+ @Mock private User user;
+ @Mock private QueryOptions queryOptions;
+
+ @Mock private CachedDataManager cachedDataManager;
+ @Mock private CachedPermissionData cachedPermissionData;
+ @Mock private CachedMetaData cachedMetaData;
+
+ private PlaceholderContext ctx;
+
+ @BeforeEach
+ public void setupMocks() {
+ lenient().when(this.user.getCachedData()).thenReturn(this.cachedDataManager);
+ lenient().when(this.cachedDataManager.getPermissionData(this.queryOptions)).thenReturn(this.cachedPermissionData);
+ lenient().when(this.cachedDataManager.getMetaData(this.queryOptions)).thenReturn(this.cachedMetaData);
+
+ this.ctx = new PlaceholderContext(this.api, this.user, this.queryOptions);
+ }
+
+ // test some of the basic / simple / most used placeholders - the others are too difficult to test
+ // using mocks only.
+
+ @Test
+ public void testPrefix() {
+ when(this.cachedMetaData.getPrefix()).thenReturn("test prefix");
+ assertEquals("test prefix", Placeholders.PREFIX.resolve(this.ctx));
+ }
+
+ @Test
+ public void testSuffix() {
+ when(this.cachedMetaData.getSuffix()).thenReturn("test suffix");
+ assertEquals("test suffix", Placeholders.SUFFIX.resolve(this.ctx));
+ }
+
+ @Test
+ public void testMeta() {
+ when(this.cachedMetaData.getMetaValue("test_key")).thenReturn("hello");
+ assertEquals("hello", Placeholders.META.resolve(this.ctx.withArgument("test_key")));
+ }
+
+ @Test
+ public void testFormatDuration() {
+ Duration duration = ChronoUnit.YEARS.getDuration().multipliedBy(5)
+ .plus(ChronoUnit.MONTHS.getDuration().multipliedBy(4))
+ .plus(ChronoUnit.WEEKS.getDuration().multipliedBy(3))
+ .plusDays(2)
+ .plusHours(1)
+ .plusMinutes(6)
+ .plusSeconds(7);
+
+ assertEquals("5y 4mo 3w 2d 1h 6m 7s", Placeholders.formatDuration(duration));
+ assertEquals("1m 10s", Placeholders.formatDuration(Duration.ofMinutes(1).plusSeconds(10)));
+ assertEquals("0s", Placeholders.formatDuration(Duration.ZERO));
+ }
+
+}
diff --git a/common/placeholders/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/common/placeholders/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker
new file mode 100644
index 0000000000..ca6ee9cea8
--- /dev/null
+++ b/common/placeholders/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker
@@ -0,0 +1 @@
+mock-maker-inline
\ No newline at end of file
diff --git a/settings.gradle b/settings.gradle
index c4e6bb3cac..6c3dd0ec1a 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -22,6 +22,7 @@ include (
'common',
'common:loader-utils',
'common:minecraft',
+ 'common:placeholders',
'bukkit',
'bukkit:loader',
'bukkit-legacy',