diff --git a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/Completer.java b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/Completer.java new file mode 100644 index 0000000000..9a44f5d818 --- /dev/null +++ b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/Completer.java @@ -0,0 +1,145 @@ +/* + * This file is part of adventure, licensed under the MIT License. + * + * Copyright (c) 2017-2025 KyoriPowered + * + * 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 net.kyori.adventure.text.minimessage; + +import java.util.Set; +import java.util.function.BiConsumer; +import org.jetbrains.annotations.NotNull; + +/** + * Functional interface that creates autocompletions. + * + * @since 123.123.123 + */ +@FunctionalInterface +public interface Completer { + /** + * Add completions to builder from context. + * + * @param completionContext the context + * @param builder the builder + * @since 123.123.123 + */ + void complete(final @NotNull CompletionContext completionContext, final CompletionResult.@NotNull Builder builder); + + /** + * Get a completer that doesn't add any completions. + * + * @return the completer. + * @since 123.123.123 + */ + static Completer none() { + return (context, builder) -> { + }; + } + + /** + * Get a completer that completes only the tag key. + * + * @param key the key + * @return the completer + * @since 123.123.123 + */ + static Completer noArgs(final String key) { + return (context, builder) -> { + if (context instanceof CompletionContext.TagKey) { + if (key.toLowerCase().startsWith(context.partial().toLowerCase())) { + builder.add(key); + } + } + }; + } + + /** + * Get a completer that completes only the tag keys. + * + * @param keys the keys + * @return the completer + * @since 123.123.123 + */ + static Completer noArgs(final Set keys) { + return (context, builder) -> { + if (context instanceof CompletionContext.TagKey) { + keys.stream() + .filter(key -> key.toLowerCase().startsWith(context.partial().toLowerCase())) + .forEach(builder::add); + } + }; + } + + /** + * Get a completer that completes tag keys, argument names and named argument values. + * + * @param tagKeys the tag keys + * @param argKeys the argument keys + * @param valueCompleter the named argument value completer + * @return the completer + * @since 123.123.123 + */ + static Completer named(final Set tagKeys, final Set argKeys, final BiConsumer valueCompleter) { + return (context, builder) -> { + if (context instanceof CompletionContext.TagKey) { + tagKeys.stream() + .filter(key -> key.toLowerCase().startsWith(context.partial().toLowerCase())) + .forEach(builder::add); + } else if (context instanceof CompletionContext.NamedArgumentKey) { + final CompletionContext.NamedArgumentKey keyContext = (CompletionContext.NamedArgumentKey) context; + if (tagKeys.contains(keyContext.tagKey().toLowerCase())) { + argKeys.stream() + .filter(key -> key.toLowerCase().startsWith(context.partial().toLowerCase())) + .filter(key -> keyContext.arguments().containsKey(key.toLowerCase())) + .forEach(builder::add); + } + } else if (context instanceof CompletionContext.NamedArgumentValue) { + final CompletionContext.NamedArgumentValue valueContext = (CompletionContext.NamedArgumentValue) context; + if (tagKeys.contains(valueContext.tagKey().toLowerCase())) { + valueCompleter.accept((CompletionContext.NamedArgumentValue) context, builder); + } + } + }; + } + + /** + * Get a completer that completes tag keys and sequential argument values. + * + * @param tagKeys the tag keys + * @param argumentValueCompleter the sequential argument values + * @return the completer + * @since 123.123.123 + */ + static Completer sequential(final Set tagKeys, final BiConsumer argumentValueCompleter) { + return (context, builder) -> { + if (context instanceof CompletionContext.TagKey) { + tagKeys.stream() + .filter(key -> key.toLowerCase().startsWith(context.partial().toLowerCase())) + .forEach(builder::add); + } else if (context instanceof CompletionContext.SequentialArgumentValue) { + final CompletionContext.SequentialArgumentValue valueContext = (CompletionContext.SequentialArgumentValue) context; + if (tagKeys.contains(valueContext.tagKey().toLowerCase())) { + argumentValueCompleter.accept(valueContext, builder); + } + } + }; + } +} diff --git a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/CompletionContext.java b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/CompletionContext.java new file mode 100644 index 0000000000..19be8c7ce2 --- /dev/null +++ b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/CompletionContext.java @@ -0,0 +1,139 @@ +/* + * This file is part of adventure, licensed under the MIT License. + * + * Copyright (c) 2017-2025 KyoriPowered + * + * 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 net.kyori.adventure.text.minimessage; + +import java.util.List; +import java.util.Map; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Context used to get Tag Completions. + * + * @since 123.123.123 + */ +@ApiStatus.NonExtendable +public interface CompletionContext { + /** + * Get the unfinished partial value to complete. + * + * @return the partial value + * @since 123.123.123 + */ + @NotNull String partial(); + + /** + * Completion context for a tag key. + * + * @since 123.123.123 + */ + interface TagKey extends CompletionContext { + } + + /** + * Completion context for the value of a sequential argument. + * + * @since 123.123.123 + */ + interface SequentialArgumentValue extends CompletionContext { + /** + * Get the tag key. + * + * @return the tag key + * @since 123.123.123 + */ + @NotNull String tagKey(); + + /** + * Get the arguments list. + * + * @return the list of arguments + * @since 123.123.123 + */ + @NotNull List arguments(); + } + + /** + * Completion context for a named argument key. + * + * @since 123.123.123 + */ + interface NamedArgumentKey extends CompletionContext { + /** + * Get the tag key. + * + * @return the tag key + * @since 123.123.123 + */ + @NotNull String tagKey(); + + /** + * Get the arguments list. + * + * @return the map of named arguments + * @since 123.123.123 + */ + @NotNull Map arguments(); + } + + /** + * Completion context for the value of a named argument. + * + * @since 123.123.123 + */ + interface NamedArgumentValue extends CompletionContext { + /** + * Get the tag key. + * + * @return the tag key + * @since 123.123.123 + */ + @NotNull String tagKey(); + + /** + * Get the arguments list. + * + * @return the map of named arguments + * @since 123.123.123 + */ + @NotNull Map arguments(); + + /** + * Get the key of the argument whose value is currently being parsed. + * Returns null in every state except NAMED_TAG_VALUE + * + * @return the argument key + */ + @Nullable String argKey(); + } + + /** + * Completion context generation when bad syntax is encountered. + * + * @since 123.123.123 + */ + interface BadSyntax extends CompletionContext { + } +} diff --git a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/CompletionContextImpl.java b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/CompletionContextImpl.java new file mode 100644 index 0000000000..f82b879097 --- /dev/null +++ b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/CompletionContextImpl.java @@ -0,0 +1,243 @@ +/* + * This file is part of adventure, licensed under the MIT License. + * + * Copyright (c) 2017-2025 KyoriPowered + * + * 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 net.kyori.adventure.text.minimessage; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +class CompletionContextImpl implements CompletionContext { + final int marker; + final String partial; + + private CompletionContextImpl(final int marker, final String partial) { + this.marker = marker; + this.partial = partial; + } + + static CompletionContextImpl create(final String partialTag) { + boolean isClosingTag = false; + String tagName = null; + final Map namedArguments = new HashMap<>(); + final List arguments = new ArrayList<>(); + CompletionState state = CompletionState.TAG_NAME; + boolean escaped = false; + boolean string = false; + int stringChar = -1; + int marker = 0; + String argName = ""; + + final int length = partialTag.length(); + for (int i = 0; i < length; i++) { + final int codePoint = partialTag.codePointAt(i); + + // Check if closing tag. + if (i == 0 && codePoint == '/') { + isClosingTag = true; + marker = 1; + continue; + } + + if (!escaped) { + // Tag name ends when we encounter a space or a colon -> save it + switch (state) { + case TAG_NAME: + switch (codePoint) { + case ' ': + state = CompletionState.NAMED_ARGUMENT_NAME; + tagName = partialTag.substring(marker, i); + marker = i + 1; + break; + case ':': + state = CompletionState.ARGUMENT_VALUE; + tagName = partialTag.substring(marker, i); + marker = i + 1; + break; + } + break; + // A named argument name ends when we encounter an equals sign + case NAMED_ARGUMENT_NAME: + if (codePoint == '=') { + argName = partialTag.substring(marker, i); + marker = i + 1; + state = CompletionState.NAMED_ARGUMENT_VALUE; + } + break; + + case ARGUMENT_VALUE: + case NAMED_ARGUMENT_VALUE: + switch (codePoint) { + // Keep track of whether we are in a string + case '\"': + case '\'': + if (string) { + if (codePoint == stringChar) { + string = false; + } + } else { + string = true; + stringChar = codePoint; + } + break; + case ' ': + if (state == CompletionState.NAMED_ARGUMENT_VALUE && !string) { + namedArguments.put(argName, partialTag.substring(marker, i)); + marker = i + 1; + state = CompletionState.NAMED_ARGUMENT_NAME; + } + break; + case ':': + if (state == CompletionState.ARGUMENT_VALUE && !string) { + arguments.add(partialTag.substring(marker, i)); + marker = i + 1; + } + break; + } + break; + } + } + + // Set escape value for next iteration. + escaped = codePoint == '\\'; + } + + final String partial = partialTag.substring(marker, length); + + switch (state) { + case TAG_NAME: + return new TagKeyImpl(marker, partial); + case ARGUMENT_VALUE: + return new SeqArgValueImpl(marker, partial, tagName, arguments); + case NAMED_ARGUMENT_NAME: + return new NamedArgKeyImpl(marker, partial, tagName, namedArguments); + case NAMED_ARGUMENT_VALUE: + return new NamedArgValueImpl(marker, partial, tagName, namedArguments, argName); + } + + // Error state + return new BadSyntaxImpl(marker, partial); + } + + int offset() { + return this.marker; + } + + @Override + public @NotNull String partial() { + return this.partial; + } + + final static class TagKeyImpl extends CompletionContextImpl implements TagKey { + private TagKeyImpl(final int marker, final String partial) { + super(marker, partial); + } + } + + final static class SeqArgValueImpl extends CompletionContextImpl implements SequentialArgumentValue { + private final String tagKey; + private final List arguments; + + private SeqArgValueImpl(final int marker, final String partial, final String tagKey, final List arguments) { + super(marker, partial); + this.tagKey = tagKey; + this.arguments = arguments; + } + + @Override + public @NotNull String tagKey() { + return this.tagKey; + } + + @Override + public @NotNull List arguments() { + return this.arguments; + } + } + + final static class NamedArgKeyImpl extends CompletionContextImpl implements NamedArgumentKey { + private final String tagKey; + private final Map arguments; + + private NamedArgKeyImpl(final int marker, final String partial, final String tagKey, final Map arguments) { + super(marker, partial); + this.tagKey = tagKey; + this.arguments = arguments; + } + + @Override + public @NotNull String tagKey() { + return this.tagKey; + } + + @Override + public @NotNull Map arguments() { + return this.arguments; + } + } + + final static class NamedArgValueImpl extends CompletionContextImpl implements NamedArgumentValue { + private final String tagKey; + private final Map arguments; + private final String argumentKey; + + private NamedArgValueImpl(final int marker, final String partial, final String tagKey, final Map arguments, final String argumentKey) { + super(marker, partial); + this.tagKey = tagKey; + this.arguments = arguments; + this.argumentKey = argumentKey; + } + + @Override + public @NotNull String tagKey() { + return this.tagKey; + } + + @Override + public @NotNull Map arguments() { + return this.arguments; + } + + @Override + public @Nullable String argKey() { + return this.argumentKey; + } + } + + final static class BadSyntaxImpl extends CompletionContextImpl implements BadSyntax { + BadSyntaxImpl(final int marker, final String partial) { + super(marker, partial); + } + } + + enum CompletionState { + TAG_NAME, + NAMED_ARGUMENT_NAME, + NAMED_ARGUMENT_VALUE, + ARGUMENT_VALUE, + BAD_SYNTAX + } +} diff --git a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/CompletionResult.java b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/CompletionResult.java new file mode 100644 index 0000000000..67be4ccebb --- /dev/null +++ b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/CompletionResult.java @@ -0,0 +1,128 @@ +/* + * This file is part of adventure, licensed under the MIT License. + * + * Copyright (c) 2017-2025 KyoriPowered + * + * 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 net.kyori.adventure.text.minimessage; + +import java.util.Collection; +import net.kyori.adventure.text.Component; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * The results for a completion. + * + * @since 123.123.123 + */ +@ApiStatus.NonExtendable +public interface CompletionResult { + /** + * Get the index from where all the completions start. + * + * @return the start index + * @since 123.123.123 + */ + int start(); + + /** + * Get the completions. + * + * @return the completions + * @since 123.123.123 + */ + @NotNull Collection completions(); + + /** + * A completion. + * + * @since 123.123.123 + */ + interface Completion { + /** + * Return the completion. + * + * @return the completion + * @since 123.123.123 + */ + @NotNull String completion(); + + /** + * Return the completion component. + * + * @return the completion component + * @since 123.123.123 + */ + @Nullable Component completionComponent(); + } + + /** + * Builder for a completion result. + * + * @since 123.123.123 + */ + interface Builder { + /** + * Create a completion result. + * Will replace the entire partial value. + * + * @param completion the completion + * @since 123.123.123 + */ + default void add(final String completion) { + this.add(completion, null, 0); + } + + /** + * Create a completion result. + * + * @param completion the completion + * @param start the start index from where to complete + * @since 123.123.123 + */ + default void add(final @NotNull String completion, final int start) { + this.add(completion, null, start); + } + + /** + * Create a completion result. + * Will replace the entire partial value. + * + * @param completion the completion + * @param completionComponent the on hover display + * @since 123.123.123 + */ + default void add(final @NotNull String completion, final @Nullable Component completionComponent) { + this.add(completion, completionComponent, 0); + } + + /** + * Create a completion result. + * + * @param completion the completion + * @param completionComponent the on hover display + * @param start the start index from where to complete + * @since 123.123.123 + */ + void add(final @NotNull String completion, final @Nullable Component completionComponent, final int start); + } +} diff --git a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/CompletionResultImpl.java b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/CompletionResultImpl.java new file mode 100644 index 0000000000..32a2b4fac0 --- /dev/null +++ b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/CompletionResultImpl.java @@ -0,0 +1,114 @@ +/* + * This file is part of adventure, licensed under the MIT License. + * + * Copyright (c) 2017-2025 KyoriPowered + * + * 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 net.kyori.adventure.text.minimessage; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import net.kyori.adventure.text.Component; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +final class CompletionResultImpl implements CompletionResult { + final int start; + final Collection completions; + + private CompletionResultImpl(final int start, final @NotNull Collection completions) { + this.start = start; + this.completions = completions; + } + + @Override + public int start() { + return this.start; + } + + @Override + public @NotNull Collection completions() { + return this.completions; + } + + final static class CompletionImpl implements Completion { + private final String completion; + private final Component completionComponent; + + private CompletionImpl(final @NotNull String value, final @Nullable Component completionComponent) { + this.completion = value; + this.completionComponent = completionComponent; + } + + @Override + public @NotNull String completion() { + return this.completion; + } + + @Override + public @Nullable Component completionComponent() { + return this.completionComponent; + } + } + + static class BuilderImpl implements CompletionResult.Builder { + List completions = new ArrayList<>(); + + @Override + public void add(final @NotNull String completion, final @Nullable Component completionComponent, final int start) { + this.completions.add(new BuilderCompletion(completion, completionComponent, start)); + } + + CompletionResultImpl build(final CompletionContextImpl context) { + final int offset = context.offset(); + + int minStart = -1; + for (final BuilderCompletion completion : this.completions) { + if (minStart == -1 || completion.start < minStart) { + minStart = completion.start; + } + } + + final Collection compls = new ArrayList<>(this.completions.size()); + for (final BuilderCompletion completion : this.completions) { + if (completion.start > minStart) { + compls.add(new CompletionImpl(context.partial().substring(minStart, completion.start), completion.completionComponent)); + } else { + compls.add(new CompletionImpl(completion.completion, completion.completionComponent)); + } + } + + return new CompletionResultImpl(offset + minStart, compls); + } + + private static class BuilderCompletion { + String completion; + Component completionComponent; + int start; + + BuilderCompletion(final String completion, final @Nullable Component completionComponent, final int start) { + this.completion = completion; + this.completionComponent = completionComponent; + this.start = start; + } + } + } +} diff --git a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/Context.java b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/Context.java index 078979669c..b286fdf75f 100644 --- a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/Context.java +++ b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/Context.java @@ -26,6 +26,7 @@ import net.kyori.adventure.pointer.Pointered; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.minimessage.tag.resolver.ArgumentQueue; +import net.kyori.adventure.text.minimessage.tag.resolver.NamedArgumentMap; import net.kyori.adventure.text.minimessage.tag.resolver.TagResolver; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; @@ -117,6 +118,19 @@ public interface Context { final @NotNull ArgumentQueue tags ); + /** + * Create a new parsing exception. + * + * @param message a detail message describing the error + * @param tags the tag parts which caused the error + * @return the new parsing exception + * @since 4.25.0 + */ + @NotNull ParsingException newException( + final @NotNull String message, + final @NotNull NamedArgumentMap tags + ); + /** * Create a new parsing exception without reference to a specific location. * @@ -141,6 +155,20 @@ public interface Context { final @NotNull ArgumentQueue args ); + /** + * Create a new parsing exception. + * + * @param message a detail message describing the error + * @param cause the cause + * @param args arguments that caused the errors + * @return the new parsing exception + */ + @NotNull ParsingException newException( + final @NotNull String message, + final @Nullable Throwable cause, + final @NotNull NamedArgumentMap args + ); + /** * Dictates if transformations may emit virtual components or not. * diff --git a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/ContextImpl.java b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/ContextImpl.java index 4b59800c40..dca372496e 100644 --- a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/ContextImpl.java +++ b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/ContextImpl.java @@ -24,6 +24,7 @@ package net.kyori.adventure.text.minimessage; import java.util.List; +import java.util.Map; import java.util.function.Consumer; import java.util.function.UnaryOperator; import net.kyori.adventure.pointer.Pointered; @@ -33,6 +34,7 @@ import net.kyori.adventure.text.minimessage.internal.parser.node.TagPart; import net.kyori.adventure.text.minimessage.tag.Tag; import net.kyori.adventure.text.minimessage.tag.resolver.ArgumentQueue; +import net.kyori.adventure.text.minimessage.tag.resolver.NamedArgumentMap; import net.kyori.adventure.text.minimessage.tag.resolver.TagResolver; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -149,7 +151,7 @@ public UnaryOperator preProcessor() { } @Override - public @NotNull Component deserialize(final @NotNull String message, final @NotNull TagResolver@NotNull... resolvers) { + public @NotNull Component deserialize(final @NotNull String message, final @NotNull TagResolver @NotNull ... resolvers) { requireNonNull(message, "message"); final TagResolver combinedResolver = TagResolver.builder().resolver(this.tagResolver).resolvers(resolvers).build(); return this.deserializeWithOptionalTarget(message, combinedResolver); @@ -165,11 +167,21 @@ public UnaryOperator preProcessor() { return new ParsingExceptionImpl(message, this.message, null, false, tagsToTokens(((ArgumentQueueImpl) tags).args)); } + @Override + public @NotNull ParsingException newException(final @NotNull String message, final @NotNull NamedArgumentMap tags) { + return new ParsingExceptionImpl(message, this.message, null, false, tagsToTokens(((NamedArgumentMapImpl) tags).args)); + } + @Override public @NotNull ParsingException newException(final @NotNull String message, final @Nullable Throwable cause, final @NotNull ArgumentQueue tags) { return new ParsingExceptionImpl(message, this.message, cause, false, tagsToTokens(((ArgumentQueueImpl) tags).args)); } + @Override + public @NotNull ParsingException newException(final @NotNull String message, final @Nullable Throwable cause, final @NotNull NamedArgumentMap args) { + return new ParsingExceptionImpl(message, this.message, cause, false, tagsToTokens(((NamedArgumentMapImpl) args).args)); + } + private @NotNull Component deserializeWithOptionalTarget(final @NotNull String message, final @NotNull TagResolver tagResolver) { if (this.target != null) { return this.miniMessage.deserialize(message, this.target, tagResolver); @@ -186,4 +198,13 @@ private static Token[] tagsToTokens(final List tags) { return tokens; } + private static Token[] tagsToTokens(final Map tags) { + final Token[] tokens = new Token[tags.size()]; + + int index = 0; + for (final Tag.Argument value : tags.values()) { + tokens[index++] = ((TagPart) value).token(); + } + return tokens; + } } diff --git a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/MiniMessage.java b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/MiniMessage.java index e6f4ab80e2..b2e90b2618 100644 --- a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/MiniMessage.java +++ b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/MiniMessage.java @@ -276,6 +276,15 @@ public interface MiniMessage extends ComponentSerializer { + sequentialTagProvider = (name, args, token) -> { try { - debug.accept("Attempting to match node '"); + debug.accept("Attempting to match node as sequential '"); debug.accept(name); debug.accept("'"); if (token != null) { @@ -167,7 +169,48 @@ private void processTokens(final @NotNull StringBuilder sb, final @NotNull Strin if (token != null && e instanceof ParsingExceptionImpl) { final ParsingExceptionImpl impl = (ParsingExceptionImpl) e; if (impl.tokens().length == 0) { - impl.tokens(new Token[] {token}); + impl.tokens(new Token[]{token}); + } + } + debug.accept("Could not match node '"); + debug.accept(name); + debug.accept("' - "); + debug.accept(e.getMessage()); + debug.accept("\n"); + return null; + } + }; + namedTagProvider = (name, args, token) -> { + try { + debug.accept("Attempting to match node as named '"); + debug.accept(name); + debug.accept("'"); + if (token != null) { + debug.accept(" at column "); + debug.accept(String.valueOf(token.startIndex())); + } + debug.accept("\n"); + + final @Nullable Tag transformation = combinedResolver.resolveNamed(name, new NamedArgumentMapImpl<>(context, args), context); + + if (transformation == null) { + debug.accept("Could not match node '"); + debug.accept(name); + debug.accept("'\n"); + } else { + debug.accept("Successfully matched node '"); + debug.accept(name); + debug.accept("' to tag "); + debug.accept(transformation instanceof Examinable ? ((Examinable) transformation).examinableName() : transformation.getClass().getName()); + debug.accept("\n"); + } + + return transformation; + } catch (final ParsingException e) { + if (token != null && e instanceof ParsingExceptionImpl) { + final ParsingExceptionImpl impl = (ParsingExceptionImpl) e; + if (impl.tokens().length == 0) { + impl.tokens(new Token[]{token}); } } debug.accept("Could not match node '"); @@ -179,14 +222,23 @@ private void processTokens(final @NotNull StringBuilder sb, final @NotNull Strin } }; } else { - transformationFactory = (name, args, token) -> { + sequentialTagProvider = (name, args, token) -> { try { return combinedResolver.resolve(name, new ArgumentQueueImpl<>(context, args), context); } catch (final ParsingException ignored) { return null; } }; + namedTagProvider = (name, args, token) -> { + try { + return combinedResolver.resolveNamed(name, new NamedArgumentMapImpl<>(context, args), context); + } catch (final ParsingException ignored) { + return null; + } + }; } + + final TokenParser.TagProvider transformationFactory = new TokenParser.TagProviderImpl(sequentialTagProvider, namedTagProvider); final Predicate tagNameChecker = name -> { final String sanitized = TokenParser.TagProvider.sanitizePlaceholderName(name); return combinedResolver.has(sanitized); diff --git a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/MiniMessageSerializer.java b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/MiniMessageSerializer.java index db385cd5e2..02566113bd 100644 --- a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/MiniMessageSerializer.java +++ b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/MiniMessageSerializer.java @@ -199,6 +199,18 @@ void completeTag() { return this; } + @Override + public @NotNull TokenEmitter namedArgument(final @NotNull String name, final @NotNull String arg) { + if (!this.tagState.isTag) { + throw new IllegalStateException("Not within a tag!"); + } + this.consumer.append(' '); + this.consumer.append(name); + this.consumer.append(TokenParser.NAME_VALUE_SEPARATOR); + this.escapeTagContent(arg, null); + return this; + } + @Override public @NotNull TokenEmitter argument(final @NotNull String arg, final @NotNull QuotingOverride quotingPreference) { if (!this.tagState.isTag) { @@ -209,12 +221,40 @@ void completeTag() { return this; } + @Override + public @NotNull TokenEmitter namedArgument(final @NotNull String name, final @NotNull String arg, final @NotNull QuotingOverride quotingPreference) { + if (!this.tagState.isTag) { + throw new IllegalStateException("Not within a tag!"); + } + this.consumer.append(' '); + this.consumer.append(name); + this.consumer.append(TokenParser.NAME_VALUE_SEPARATOR); + this.escapeTagContent(arg, requireNonNull(quotingPreference, "quotingPreference")); + return this; + } + @Override public @NotNull TokenEmitter argument(final @NotNull Component arg) { final String serialized = MiniMessageSerializer.serialize(arg, this.resolver, this.strict); return this.argument(serialized, QuotingOverride.QUOTED); // always quote tokens } + @Override + public @NotNull TokenEmitter namedArgument(final @NotNull String name, final @NotNull Component arg) { + final String serialized = MiniMessageSerializer.serialize(arg, this.resolver, this.strict); + return this.namedArgument(name, serialized, QuotingOverride.QUOTED); // always quote tokens + } + + @Override + public @NotNull TokenEmitter flag(final @NotNull String name, final boolean value) { + if (!this.tagState.isTag) { + throw new IllegalStateException("Not within a tag!"); + } + this.consumer.append(' '); + this.consumer.append(value ? name : '!' + name); + return this; + } + @Override public @NotNull Collector text(final @NotNull String text) { this.completeTag(); diff --git a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/NamedArgumentMapImpl.java b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/NamedArgumentMapImpl.java new file mode 100644 index 0000000000..8975d89316 --- /dev/null +++ b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/NamedArgumentMapImpl.java @@ -0,0 +1,105 @@ +/* + * This file is part of adventure, licensed under the MIT License. + * + * Copyright (c) 2017-2025 KyoriPowered + * + * 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 net.kyori.adventure.text.minimessage; + +import java.util.Map; +import java.util.function.Supplier; +import net.kyori.adventure.text.minimessage.tag.Tag; +import net.kyori.adventure.text.minimessage.tag.resolver.NamedArgumentMap; +import net.kyori.adventure.util.TriState; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import static java.util.Objects.requireNonNull; + +final class NamedArgumentMapImpl implements NamedArgumentMap { + private final Context context; + final Map args; + + NamedArgumentMapImpl(final Context context, final Map args) { + this.context = context; + this.args = args; + } + + @Override + public boolean isPresent(final @NotNull String name) { + requireNonNull(name, "name"); + return this.args.containsKey(name); + } + + @Override + public int size() { + return this.args.size(); + } + + @Override + public Tag.@Nullable Argument get(final @NotNull String name) { + requireNonNull(name, "name"); + return this.args.get(name); + } + + @Override + public @NotNull TriState flag(final @NotNull String name) { + final Tag.Argument argument = this.get(name); + if (argument == null) { + // The normal flag is not preset, so try the inverted flag + final Tag.Argument invertedArgument = this.get('!' + name); + if (invertedArgument == null) { + return TriState.NOT_SET; + } + + return TriState.FALSE; + } + + return TriState.TRUE; + } + + @Override + public boolean isFlagPresent(final @NotNull String name) { + if (this.isPresent(name)) { + return true; + } + return this.isPresent('!' + name); + } + + @Override + public Tag.@NotNull Argument orThrow(final @NotNull String name, final @NotNull String errorMessage) { + requireNonNull(errorMessage, "errorMessage"); + final Tag.Argument arg = this.get(name); + if (arg == null) { + throw this.context.newException(errorMessage); + } + return arg; + } + + @Override + public Tag.@NotNull Argument orThrow(final @NotNull String name, final @NotNull Supplier errorMessage) { + requireNonNull(errorMessage, "errorMessage"); + final Tag.Argument arg = this.get(name); + if (arg == null) { + throw this.context.newException(errorMessage.get()); + } + return arg; + } +} diff --git a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/internal/parser/TokenParser.java b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/internal/parser/TokenParser.java index fff63081ab..f739b6f360 100644 --- a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/internal/parser/TokenParser.java +++ b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/internal/parser/TokenParser.java @@ -28,6 +28,8 @@ import java.util.List; import java.util.ListIterator; import java.util.Locale; +import java.util.Map; +import java.util.TreeMap; import java.util.function.IntPredicate; import java.util.function.Predicate; import net.kyori.adventure.text.minimessage.ParsingException; @@ -43,6 +45,7 @@ import net.kyori.adventure.text.minimessage.tag.Inserting; import net.kyori.adventure.text.minimessage.tag.ParserDirective; import net.kyori.adventure.text.minimessage.tag.Tag; +import net.kyori.adventure.util.TriState; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -60,6 +63,7 @@ public final class TokenParser { public static final char TAG_END = '>'; public static final char CLOSE_TAG = '/'; public static final char SEPARATOR = ':'; + public static final char NAME_VALUE_SEPARATOR = '='; // misc public static final char ESCAPE = '\\'; @@ -305,6 +309,9 @@ private static void parseSecondPass(final String message, final List toke boolean escaped = false; char currentStringChar = 0; + TriState namedArguments = TriState.NOT_SET; + boolean nextNormalIsArgumentValue = false; + // Marker is the starting index for the current token int marker = startIndex; @@ -344,6 +351,13 @@ private static void parseSecondPass(final String message, final List toke case NORMAL: // Values are split by : unless it's in a URL if (codePoint == SEPARATOR) { + if (namedArguments == TriState.NOT_SET) { + namedArguments = TriState.FALSE; + } else if (namedArguments == TriState.TRUE) { + // If the arguments are named, colons should be interpreted as plain text. + break; + } + if (boundsCheck(message, i, 2) && message.charAt(i + 1) == '/' && message.charAt(i + 2) == '/') { break; } @@ -358,6 +372,43 @@ private static void parseSecondPass(final String message, final List toke } else if (codePoint == '\'' || codePoint == '"') { state = SecondPassState.STRING; currentStringChar = (char) codePoint; + } else if (codePoint == ' ') { + if (namedArguments == TriState.NOT_SET) { + // Having a whitespace here is nice and all, but there is a slight issue. In the event of a tag just looking like this , + // it should not actually be interpreted as a named argument tag, since it has no arguments which would actually use that. + // We can simply check whether the remainer of this message is blank. + final String substring = message.substring(i, endIndex); + if (isBlank(substring)) { + i += substring.length(); + break; + } + + insert(token, new Token(marker, i, TokenType.TAG_VALUE)); + namedArguments = TriState.TRUE; + marker = i + 1; + break; + } else if (namedArguments == TriState.FALSE) { + // If the arguments are unnamed, spaces are to be interpreted literally + break; + } + + if (marker == i) { + // If two whitespace follow up on each other, like , we just want to move the marker up by one without creating a token + marker++; + break; + } + + insert(token, new Token(marker, i, nextNormalIsArgumentValue ? TokenType.TAG_VALUE : TokenType.TAG_VALUE_TOGGLE)); + marker = i + 1; + nextNormalIsArgumentValue = false; + } else if (codePoint == NAME_VALUE_SEPARATOR) { + if (namedArguments != TriState.TRUE) { + break; + } + + nextNormalIsArgumentValue = true; + insert(token, new Token(marker, i, TokenType.TAG_VALUE_NAME)); + marker = i + 1; } break; case STRING: @@ -371,6 +422,17 @@ private static void parseSecondPass(final String message, final List toke // anything not matched is the final part if (token.childTokens() == null || token.childTokens().isEmpty()) { insert(token, new Token(startIndex, endIndex, TokenType.TAG_VALUE)); + } else if (namedArguments == TriState.TRUE) { + if (marker < endIndex) { + if (nextNormalIsArgumentValue) { + insert(token, new Token(marker, endIndex, TokenType.TAG_VALUE)); + } else { + // If there are only whitespace characters remaining, we do not want to create a new token here, as it would be empty + if (!isBlank(message.substring(marker, endIndex))) { + insert(token, new Token(marker, endIndex, TokenType.TAG_VALUE_TOGGLE)); + } + } + } } else { final int end = token.childTokens().get(token.childTokens().size() - 1).endIndex(); if (end != endIndex) { @@ -380,6 +442,19 @@ private static void parseSecondPass(final String message, final List toke } } + private static boolean isBlank(final CharSequence cs) { + int index = 0; + boolean isBlank = true; + while (index < cs.length()) { + if (!Character.isWhitespace(cs.charAt(index++))) { + isBlank = false; + break; + } + } + + return isBlank; + } + /* * Build a tree from the OPEN_TAG and CLOSE_TAG tokens */ @@ -455,7 +530,7 @@ private static RootNode buildTree( final String closeTagName = closeValues.get(0); if (tagNameChecker.test(closeTagName)) { - final Tag tag = tagProvider.resolve(closeTagName); + final Tag tag = tagProvider.resolveSequential(closeTagName); if (tag == ParserDirective.RESET) { // This is a synthetic node, closing it means nothing in the context of building a tree @@ -659,12 +734,35 @@ public static String unescape(final String text, final int startIndex, final int } /** - * Normalizing provider for tag information. + * A special tag provider for tags with queued arguments. * - * @since 4.10.0 + * @param argument + * @since 4.25.0 + */ + @ApiStatus.Internal + public interface SequentialTagProvider { + /** + * Look up a tag. + * + *

Parsing exceptions must be caught and handled within this method.

+ * + * @param name the tag name, pre-sanitized + * @param trimmedArgs arguments, with the tag name trimmed off + * @param token the token, if this tag is from a parse stream + * @return a tag + * @since 4.10.0 + */ + @Nullable Tag resolveSequential(final @NotNull String name, final @NotNull List trimmedArgs, final @Nullable Token token); + } + + /** + * A special tag provider for tags with named arguments. + * + * @param argument + * @since 4.25.0 */ @ApiStatus.Internal - public interface TagProvider { + public interface NamedTagProvider { /** * Look up a tag. * @@ -676,17 +774,92 @@ public interface TagProvider { * @return a tag * @since 4.10.0 */ - @Nullable Tag resolve(final @NotNull String name, final @NotNull List trimmedArgs, final @Nullable Token token); + @Nullable Tag resolveNamed(final @NotNull String name, final @NotNull Map trimmedArgs, final @Nullable Token token); + } + + /** + * Normalizing provider for tag information. + * + * @param tag argument + * @since 4.10.0 + */ + @ApiStatus.Internal + public interface TagProvider extends SequentialTagProvider, NamedTagProvider { + + /** + * Get whether a list of tokens contains a {@link TokenType#TAG_VALUE_NAME} or {@link TokenType#TAG_VALUE_TOGGLE}. + * + * @param trimmedTokens list of tokens + * @return whether a list of tokens contains a {@link TokenType#TAG_VALUE_NAME} or {@link TokenType#TAG_VALUE_TOGGLE} + * @since 4.25.0 + */ + default boolean isNamed(final @NotNull List trimmedTokens) { + for (final Token trimmedToken : trimmedTokens) { + if (trimmedToken.type() == TokenType.TAG_VALUE_NAME || trimmedToken.type() == TokenType.TAG_VALUE_TOGGLE) { + return true; + } + } + return false; + } + + /** + * Get whether this tag node has named arguments. + * + * @param node the node + * @return whether this tag node has named arguments + * @since 4.25.0 + */ + default boolean isNamed(final @NotNull TagNode node) { + return this.isNamed(node.token().childTokens()); + } /** * Resolve by sanitized name. * * @param name sanitized name * @return a tag, if any is available - * @since 4.10.0 + * @since 4.25.0 */ - default @Nullable Tag resolve(final @NotNull String name) { - return this.resolve(name, Collections.emptyList(), null); + default @Nullable Tag resolveSequential(final @NotNull String name) { + return this.resolveSequential(name, Collections.emptyList(), null); + } + + /** + * Resolve by sanitized name. + * + * @param name sanitized name + * @return a tag, if any is available + * @since 4.25.0 + */ + default @Nullable Tag resolveNamed(final @NotNull String name) { + return this.resolveNamed(name, Collections.emptyMap(), null); + } + + /** + * Resolve the provided node smartly. + * + *

+ * This method first checks if the node is named and then routes + * the call to either {@link #resolveNamed(TagNode)} or {@link #resolveSequential(TagNode)} + * depending on the result. + *

+ * + * @param node the node + * @return the resolved tag, or null + * @since 4.25.0 + */ + default @Nullable Tag resolve(final @NotNull TagNode node) { + if (this.isNamed(node)) { + return this.resolveNamed(node); + } + + final Tag out = this.resolveSequential(node); + if (node.parts().size() == 1 && out == null) { + // This might be a named tag which has no arguments provided. + return this.resolveNamed(node); + } + + return out; } /** @@ -694,12 +867,48 @@ public interface TagProvider { * * @param node tag node * @return a tag, if any is available - * @since 4.10.0 + * @since 4.25.0 */ - default @Nullable Tag resolve(final @NotNull TagNode node) { - return this.resolve( - sanitizePlaceholderName(node.name()), - node.parts().subList(1, node.parts().size()), + default @Nullable Tag resolveSequential(final @NotNull TagNode node) { + return this.resolveSequential( + TagProvider.sanitizePlaceholderName(node.name()), + (List) node.parts().subList(1, node.parts().size()), + node.token() + ); + } + + /** + * Resolve by node. + * + * @param node tag node + * @return a tag, if any is available + * @since 4.25.0 + */ + default @Nullable Tag resolveNamed(final @NotNull TagNode node) { + final Map map = new TreeMap<>(); + + final List parts = node.parts(); + for (int i = 1, partsSize = parts.size(); i < partsSize; i++) { + final TagPart part = parts.get(i); + + if (part.token().type() == TokenType.TAG_VALUE_NAME) { + if (i + 1 == partsSize || parts.get(i + 1).token().type() != TokenType.TAG_VALUE) { + throw new IllegalStateException("Somehow a tag name has no value afterwards."); + } + + map.put(part.value(), (T) parts.get(i + 1)); + i++; + continue; + } + + if (part.token().type() == TokenType.TAG_VALUE_TOGGLE) { + map.put(part.value(), (T) part); + } + } + + return this.resolveNamed( + TagProvider.sanitizePlaceholderName(node.name()), + map, node.token() ); } @@ -717,4 +926,48 @@ public interface TagProvider { return name.toLowerCase(Locale.ROOT); } } + + /** + * A basic implementation of a {@link TagProvider} for convenience. + * + * @param tag argument + * @since 4.25.0 + */ + @ApiStatus.Internal + public static final class TagProviderImpl implements TagProvider { + private final SequentialTagProvider sequential; + private final NamedTagProvider named; + + /** + * Construct a new {@link TagProviderImpl} object. + * + * @param sequential the sequential provider + * @param named the named provider + * @since 4.25.0 + */ + public TagProviderImpl(final SequentialTagProvider sequential, final NamedTagProvider named) { + this.sequential = sequential; + this.named = named; + } + + /** + * {@inheritDoc} + * + * @since 4.25.0 + */ + @Override + public @Nullable Tag resolveNamed(final @NotNull String name, final @NotNull Map trimmedArgs, final @Nullable Token token) { + return this.named.resolveNamed(name, trimmedArgs, token); + } + + /** + * {@inheritDoc} + * + * @since 4.25.0 + */ + @Override + public @Nullable Tag resolveSequential(final @NotNull String name, final @NotNull List trimmedArgs, final @Nullable Token token) { + return this.sequential.resolveSequential(name, trimmedArgs, token); + } + } } diff --git a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/internal/parser/TokenType.java b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/internal/parser/TokenType.java index 804f8cca7c..56c658ae40 100644 --- a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/internal/parser/TokenType.java +++ b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/internal/parser/TokenType.java @@ -33,5 +33,7 @@ public enum TokenType { OPEN_TAG, OPEN_CLOSE_TAG, // one token that both opens and closes a tag CLOSE_TAG, + TAG_VALUE_TOGGLE, + TAG_VALUE_NAME, TAG_VALUE; } diff --git a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/internal/parser/match/StringResolvingMatchedTokenConsumer.java b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/internal/parser/match/StringResolvingMatchedTokenConsumer.java index b2d6583c0d..30c585394d 100644 --- a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/internal/parser/match/StringResolvingMatchedTokenConsumer.java +++ b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/internal/parser/match/StringResolvingMatchedTokenConsumer.java @@ -28,7 +28,6 @@ import java.util.Objects; import net.kyori.adventure.text.minimessage.internal.TagInternals; import net.kyori.adventure.text.minimessage.internal.parser.Token; -import net.kyori.adventure.text.minimessage.internal.parser.TokenParser; import net.kyori.adventure.text.minimessage.internal.parser.TokenParser.TagProvider; import net.kyori.adventure.text.minimessage.internal.parser.TokenType; import net.kyori.adventure.text.minimessage.internal.parser.node.TagPart; @@ -91,7 +90,7 @@ public void accept(final int start, final int end, final @NotNull TokenType toke } } // we might care if it's a pre-process! - final @Nullable Tag replacement = this.tagProvider.resolve(TokenParser.TagProvider.sanitizePlaceholderName(tag), parts, tokens.get(0)); + final @Nullable Tag replacement = this.tagProvider.resolveSequential(TagProvider.sanitizePlaceholderName(tag), parts, tokens.get(0)); if (replacement instanceof PreProcess) { this.builder.append(Objects.requireNonNull(((PreProcess) replacement).value(), "PreProcess replacements cannot return null")); diff --git a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/internal/serializer/NamedComponentClaimingResolverImpl.java b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/internal/serializer/NamedComponentClaimingResolverImpl.java new file mode 100644 index 0000000000..28a2ba3648 --- /dev/null +++ b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/internal/serializer/NamedComponentClaimingResolverImpl.java @@ -0,0 +1,75 @@ +/* + * This file is part of adventure, licensed under the MIT License. + * + * Copyright (c) 2017-2025 KyoriPowered + * + * 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 net.kyori.adventure.text.minimessage.internal.serializer; + +import java.util.Set; +import java.util.function.BiFunction; +import java.util.function.Function; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.minimessage.Completer; +import net.kyori.adventure.text.minimessage.CompletionContext; +import net.kyori.adventure.text.minimessage.CompletionResult; +import net.kyori.adventure.text.minimessage.Context; +import net.kyori.adventure.text.minimessage.ParsingException; +import net.kyori.adventure.text.minimessage.tag.Tag; +import net.kyori.adventure.text.minimessage.tag.resolver.NamedArgumentMap; +import net.kyori.adventure.text.minimessage.tag.resolver.TagResolver; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +class NamedComponentClaimingResolverImpl implements TagResolver.Named, SerializableResolver.Single { + private final @NotNull Set names; + private final @NotNull BiFunction handler; + private final @NotNull Function componentClaim; + private final @NotNull Completer completer; + + NamedComponentClaimingResolverImpl(final Set names, final BiFunction handler, final Function componentClaim, final @Nullable Completer completer) { + this.names = names; + this.handler = handler; + this.componentClaim = componentClaim; + this.completer = completer != null ? completer : Completer.noArgs(names); + } + + @Override + public @Nullable Tag resolveNamed(final @NotNull String name, final @NotNull NamedArgumentMap arguments, final @NotNull Context ctx) throws ParsingException { + if (!this.names.contains(name)) return null; + + return this.handler.apply(arguments, ctx); + } + + @Override + public boolean has(final @NotNull String name) { + return this.names.contains(name); + } + + @Override + public void complete(final @NotNull CompletionContext completionContext, final CompletionResult.@NotNull Builder builder) { + this.completer.complete(completionContext, builder); + } + + @Override + public @Nullable Emitable claimComponent(final @NotNull Component component) { + return this.componentClaim.apply(component); + } +} diff --git a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/internal/serializer/NamedStyleClaimingResolverImpl.java b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/internal/serializer/NamedStyleClaimingResolverImpl.java new file mode 100644 index 0000000000..3b5387792d --- /dev/null +++ b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/internal/serializer/NamedStyleClaimingResolverImpl.java @@ -0,0 +1,70 @@ +/* + * This file is part of adventure, licensed under the MIT License. + * + * Copyright (c) 2017-2025 KyoriPowered + * + * 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 net.kyori.adventure.text.minimessage.internal.serializer; + +import java.util.Set; +import java.util.function.BiFunction; +import net.kyori.adventure.text.minimessage.CompletionContext; +import net.kyori.adventure.text.minimessage.CompletionResult; +import net.kyori.adventure.text.minimessage.Context; +import net.kyori.adventure.text.minimessage.ParsingException; +import net.kyori.adventure.text.minimessage.tag.Tag; +import net.kyori.adventure.text.minimessage.tag.resolver.NamedArgumentMap; +import net.kyori.adventure.text.minimessage.tag.resolver.TagResolver; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +final class NamedStyleClaimingResolverImpl implements TagResolver.Named, SerializableResolver.Single { + private final @NotNull Set names; + private final @NotNull BiFunction handler; + private final @NotNull StyleClaim styleClaim; + + NamedStyleClaimingResolverImpl(final @NotNull Set names, final @NotNull BiFunction handler, final @NotNull StyleClaim styleClaim) { + this.names = names; + this.handler = handler; + this.styleClaim = styleClaim; + } + + @Override + public @Nullable Tag resolveNamed(final @NotNull String name, final @NotNull NamedArgumentMap arguments, final @NotNull Context ctx) throws ParsingException { + if (!this.names.contains(name)) return null; + + return this.handler.apply(arguments, ctx); + } + + @Override + public boolean has(final @NotNull String name) { + return this.names.contains(name); + } + + @Override + public void complete(final @NotNull CompletionContext completionContext, final CompletionResult.@NotNull Builder builder) { + + } + + @Override + public StyleClaim claimStyle() { + return this.styleClaim; + } +} diff --git a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/internal/serializer/ComponentClaimingResolverImpl.java b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/internal/serializer/SequentialComponentClaimingResolverImpl.java similarity index 82% rename from text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/internal/serializer/ComponentClaimingResolverImpl.java rename to text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/internal/serializer/SequentialComponentClaimingResolverImpl.java index 941dbe4262..bd5f4d9b6f 100644 --- a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/internal/serializer/ComponentClaimingResolverImpl.java +++ b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/internal/serializer/SequentialComponentClaimingResolverImpl.java @@ -27,6 +27,8 @@ import java.util.function.BiFunction; import java.util.function.Function; import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.minimessage.CompletionContext; +import net.kyori.adventure.text.minimessage.CompletionResult; import net.kyori.adventure.text.minimessage.Context; import net.kyori.adventure.text.minimessage.ParsingException; import net.kyori.adventure.text.minimessage.tag.Tag; @@ -35,12 +37,12 @@ import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -class ComponentClaimingResolverImpl implements TagResolver, SerializableResolver.Single { +class SequentialComponentClaimingResolverImpl implements TagResolver.Sequential, SerializableResolver.Single { private final @NotNull Set names; private final @NotNull BiFunction handler; private final @NotNull Function componentClaim; - ComponentClaimingResolverImpl(final Set names, final BiFunction handler, final Function componentClaim) { + SequentialComponentClaimingResolverImpl(final Set names, final BiFunction handler, final Function componentClaim) { this.names = names; this.handler = handler; this.componentClaim = componentClaim; @@ -58,6 +60,11 @@ public boolean has(final @NotNull String name) { return this.names.contains(name); } + @Override + public void complete(final @NotNull CompletionContext completionContext, final CompletionResult.@NotNull Builder builder) { + + } + @Override public @Nullable Emitable claimComponent(final @NotNull Component component) { return this.componentClaim.apply(component); diff --git a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/internal/serializer/StyleClaimingResolverImpl.java b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/internal/serializer/SequentialStyleClaimingResolverImpl.java similarity index 74% rename from text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/internal/serializer/StyleClaimingResolverImpl.java rename to text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/internal/serializer/SequentialStyleClaimingResolverImpl.java index 99b82b0071..e4b9ce1057 100644 --- a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/internal/serializer/StyleClaimingResolverImpl.java +++ b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/internal/serializer/SequentialStyleClaimingResolverImpl.java @@ -25,6 +25,9 @@ import java.util.Set; import java.util.function.BiFunction; +import net.kyori.adventure.text.minimessage.Completer; +import net.kyori.adventure.text.minimessage.CompletionContext; +import net.kyori.adventure.text.minimessage.CompletionResult; import net.kyori.adventure.text.minimessage.Context; import net.kyori.adventure.text.minimessage.ParsingException; import net.kyori.adventure.text.minimessage.tag.Tag; @@ -33,15 +36,17 @@ import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -final class StyleClaimingResolverImpl implements TagResolver, SerializableResolver.Single { +final class SequentialStyleClaimingResolverImpl implements TagResolver.Sequential, SerializableResolver.Single { private final @NotNull Set names; private final @NotNull BiFunction handler; private final @NotNull StyleClaim styleClaim; + private final @NotNull Completer completer; - StyleClaimingResolverImpl(final @NotNull Set names, final @NotNull BiFunction handler, final @NotNull StyleClaim styleClaim) { + SequentialStyleClaimingResolverImpl(final @NotNull Set names, final @NotNull BiFunction handler, final @NotNull StyleClaim styleClaim, final @Nullable Completer completer) { this.names = names; this.handler = handler; this.styleClaim = styleClaim; + this.completer = completer != null ? completer : Completer.noArgs(names); } @Override @@ -56,6 +61,11 @@ public boolean has(final @NotNull String name) { return this.names.contains(name); } + @Override + public void complete(final @NotNull CompletionContext completionContext, final CompletionResult.@NotNull Builder builder) { + this.completer.complete(completionContext, builder); + } + @Override public @Nullable StyleClaim claimStyle() { return this.styleClaim; diff --git a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/internal/serializer/SerializableResolver.java b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/internal/serializer/SerializableResolver.java index ab63a18279..27e5aa3074 100644 --- a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/internal/serializer/SerializableResolver.java +++ b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/internal/serializer/SerializableResolver.java @@ -26,14 +26,19 @@ import java.util.Collections; import java.util.HashSet; import java.util.Set; +import java.util.function.BiConsumer; import java.util.function.BiFunction; import java.util.function.Function; import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.minimessage.Completer; +import net.kyori.adventure.text.minimessage.CompletionContext; +import net.kyori.adventure.text.minimessage.CompletionResult; import net.kyori.adventure.text.minimessage.Context; import net.kyori.adventure.text.minimessage.ParsingException; import net.kyori.adventure.text.minimessage.internal.TagInternals; import net.kyori.adventure.text.minimessage.tag.Tag; import net.kyori.adventure.text.minimessage.tag.resolver.ArgumentQueue; +import net.kyori.adventure.text.minimessage.tag.resolver.NamedArgumentMap; import net.kyori.adventure.text.minimessage.tag.resolver.TagResolver; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -59,6 +64,21 @@ public interface SerializableResolver { return claimingComponent(Collections.singleton(name), handler, componentClaim); } + /** + * Create a tag resolver that only responds to a single tag name, and whose value does not depend on that name. + * + *

The resolver created is a special resolver, which listens to named arguments instead of sequential ones.

+ * + * @param name the name to respond to + * @param handler the tag handler, may throw {@link ParsingException} if provided arguments are in an invalid format + * @param componentClaim the claim to test components against + * @return a resolver that creates tags using the provided handler + * @since 4.25.0 + */ + static @NotNull TagResolver claimingComponentNamed(final @NotNull String name, final @NotNull BiFunction handler, final @NotNull Function componentClaim) { + return claimingComponentNamed(Collections.singleton(name), handler, componentClaim); + } + /** * Create a tag resolver that only responds to certain tag names, and whose value does not depend on that name. * @@ -74,7 +94,50 @@ public interface SerializableResolver { TagInternals.assertValidTagName(name); } requireNonNull(handler, "handler"); - return new ComponentClaimingResolverImpl(ownNames, handler, componentClaim); + return new SequentialComponentClaimingResolverImpl(ownNames, handler, componentClaim); + } + + /** + * Create a tag resolver that only responds to certain tag names, and whose value does not depend on that name. + * + *

The resolver created is a special resolver, which listens to named arguments instead of sequential ones.

+ * + * @param names the names to respond to + * @param handler the tag handler, may throw {@link ParsingException} if provided arguments are in an invalid format + * @param componentClaim the claim to test components against + * @return a resolver that creates tags using the provided handler + * @since 4.25.0 + */ + static @NotNull TagResolver claimingComponentNamed(final @NotNull Set names, final @NotNull BiFunction handler, final @NotNull Function componentClaim) { + final Set ownNames = new HashSet<>(names); + for (final String name : ownNames) { + TagInternals.assertValidTagName(name); + } + requireNonNull(handler, "handler"); + return new NamedComponentClaimingResolverImpl(ownNames, handler, componentClaim, null); + } + + /** + * Create a tag resolver that only responds to certain tag names, and whose value does not depend on that name. + * With additional autocompletion information. + * + *

The resolver created is a special resolver, which listens to named arguments instead of sequential ones.

+ * + * @param names the names to respond to + * @param handler the tag handler, may throw {@link ParsingException} if provided arguments are in an invalid format + * @param componentClaim the claim to test components against + * @param argumentNames the possible argument names for autocompletion + * @param argumentValueCompleter a consumer that adds argument value completions to the CompletionResult Builder. + * @return a resolver that creates tags using the provided handler + * @since 123.123.123 + */ + static @NotNull TagResolver claimingComponentNamed(final @NotNull Set names, final @NotNull BiFunction handler, final @NotNull Function componentClaim, final @NotNull Set argumentNames, final @NotNull BiConsumer argumentValueCompleter) { + final Set ownNames = new HashSet<>(names); + for (final String name : ownNames) { + TagInternals.assertValidTagName(name); + } + requireNonNull(handler, "handler"); + return new NamedComponentClaimingResolverImpl(ownNames, handler, componentClaim, Completer.named(names, argumentNames, argumentValueCompleter)); } /** @@ -90,6 +153,36 @@ public interface SerializableResolver { return claimingStyle(Collections.singleton(name), handler, styleClaim); } + /** + * Create a tag resolver that only responds to a single tag name, and whose value does not depend on that name. + * With additional autocompletion information. + * + * @param name the name to respond to + * @param handler the tag handler, may throw {@link ParsingException} if provided arguments are in an invalid format + * @param styleClaim the extractor for style claims on components + * @param argumentValueCompleter argument value completer + * @return a resolver that creates tags using the provided handler + * @since 123.123.123 + */ + static @NotNull TagResolver claimingStyle(final @NotNull String name, final @NotNull BiFunction handler, final @NotNull StyleClaim styleClaim, final @NotNull BiConsumer argumentValueCompleter) { + return claimingStyle(Collections.singleton(name), handler, styleClaim, argumentValueCompleter); + } + + /** + * Create a tag resolver that only responds to a single tag name, and whose value does not depend on that name. + * + *

The resolver created is a special resolver, which listens to named arguments instead of sequential ones.

+ * + * @param name the name to respond to + * @param handler the tag handler, may throw {@link ParsingException} if provided arguments are in an invalid format + * @param styleClaim the extractor for style claims on components + * @return a resolver that creates tags using the provided handler + * @since 4.25.0 + */ + static @NotNull TagResolver claimingStyleNamed(final @NotNull String name, final @NotNull BiFunction handler, final @NotNull StyleClaim styleClaim) { + return claimingStyleNamed(Collections.singleton(name), handler, styleClaim); + } + /** * Create a tag resolver that only responds to certain tag names, and whose value does not depend on that name. * @@ -105,7 +198,47 @@ public interface SerializableResolver { TagInternals.assertValidTagName(name); } requireNonNull(handler, "handler"); - return new StyleClaimingResolverImpl(ownNames, handler, styleClaim); + return new SequentialStyleClaimingResolverImpl(ownNames, handler, styleClaim, null); + } + + /** + * Create a tag resolver that only responds to certain tag names, and whose value does not depend on that name. + * With additional autocompletion information. + * + * @param names the names to respond to + * @param handler the tag handler, may throw {@link ParsingException} if provided arguments are in an invalid format + * @param styleClaim the extractor for style claims on components + * @param argumentValueCompleter argument value completer + * @return a resolver that creates tags using the provided handler + * @since 123.123.123 + */ + static @NotNull TagResolver claimingStyle(final @NotNull Set names, final @NotNull BiFunction handler, final @NotNull StyleClaim styleClaim, final @NotNull BiConsumer argumentValueCompleter) { + final Set ownNames = new HashSet<>(names); + for (final String name : ownNames) { + TagInternals.assertValidTagName(name); + } + requireNonNull(handler, "handler"); + return new SequentialStyleClaimingResolverImpl(ownNames, handler, styleClaim, Completer.sequential(names, argumentValueCompleter)); + } + + /** + * Create a tag resolver that only responds to certain tag names, and whose value does not depend on that name. + * + *

The resolver created is a special resolver, which listens to named arguments instead of sequential ones.

+ * + * @param names the names to respond to + * @param handler the tag handler, may throw {@link ParsingException} if provided arguments are in an invalid format + * @param styleClaim the extractor for style claims on components + * @return a resolver that creates tags using the provided handler + * @since 4.25.0 + */ + static @NotNull TagResolver claimingStyleNamed(final @NotNull Set names, final @NotNull BiFunction handler, final @NotNull StyleClaim styleClaim) { + final Set ownNames = new HashSet<>(names); + for (final String name : ownNames) { + TagInternals.assertValidTagName(name); + } + requireNonNull(handler, "handler"); + return new NamedStyleClaimingResolverImpl(ownNames, handler, styleClaim); } /** diff --git a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/internal/serializer/TokenEmitter.java b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/internal/serializer/TokenEmitter.java index 19fdf37e78..bc277bc4ba 100644 --- a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/internal/serializer/TokenEmitter.java +++ b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/internal/serializer/TokenEmitter.java @@ -79,6 +79,18 @@ public interface TokenEmitter { */ @NotNull TokenEmitter argument(final @NotNull String arg); + /** + * Add a single named argument to the current tag. + * + *

Must be called after {@link #tag(String)}, but before any call to {@link #text(String)}.

+ * + * @param name name of the argument + * @param arg argument value + * @return this emitter + * @since 4.25.0 + */ + @NotNull TokenEmitter namedArgument(final @NotNull String name, final @NotNull String arg); + /** * Add a single argument to the current tag. * @@ -91,6 +103,19 @@ public interface TokenEmitter { */ @NotNull TokenEmitter argument(final @NotNull String arg, final @NotNull QuotingOverride quotingPreference); + /** + * Add a single named argument to the current tag. + * + *

Must be called after {@link #tag(String)}, but before any call to {@link #text(String)}.

+ * + * @param name name of the argument + * @param arg argument value + * @param quotingPreference an argument-specific quoting instruction + * @return this emitter + * @since 4.25.0 + */ + @NotNull TokenEmitter namedArgument(final @NotNull String name, final @NotNull String arg, final @NotNull QuotingOverride quotingPreference); + /** * Add a single argument to the current tag. * @@ -102,6 +127,30 @@ public interface TokenEmitter { */ @NotNull TokenEmitter argument(final @NotNull Component arg); + /** + * Add a single named argument to the current tag. + * + *

Must be called after {@link #tag(String)}, but before any call to {@link #text(String)}.

+ * + * @param name name of the argument + * @param arg argument value, serialized as a nested MiniMessage string + * @return this emitter + * @since 4.25.0 + */ + @NotNull TokenEmitter namedArgument(final @NotNull String name, final @NotNull Component arg); + + /** + * Adds a flag argument to the current tag. + * + *

Must be called after {@link #tag(String)}, but before any call to {@link #text(String)}.

+ * + * @param name the name of the flag + * @param value the value to set the flag to + * @return this emitter + * @since 4.25.0 + */ + @NotNull TokenEmitter flag(final @NotNull String name, final boolean value); + /** * Emit literal text. * diff --git a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/tag/resolver/CachingTagResolver.java b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/tag/resolver/CachingTagResolver.java index 22fceeef14..02844b177b 100644 --- a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/tag/resolver/CachingTagResolver.java +++ b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/tag/resolver/CachingTagResolver.java @@ -27,6 +27,8 @@ import java.util.Map; import java.util.Objects; import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.minimessage.CompletionContext; +import net.kyori.adventure.text.minimessage.CompletionResult; import net.kyori.adventure.text.minimessage.internal.serializer.ClaimConsumer; import net.kyori.adventure.text.minimessage.internal.serializer.SerializableResolver; import net.kyori.adventure.text.minimessage.tag.Inserting; @@ -64,6 +66,11 @@ public boolean has(final @NotNull String name) { return this.query(name) != NULL_REPLACEMENT; } + @Override + public void complete(final @NotNull CompletionContext completionContext, final CompletionResult.@NotNull Builder builder) { + this.resolver.complete(completionContext, builder); + } + @Override public boolean contributeToMap(final @NotNull Map map) { if (this.resolver instanceof MappableResolver) { diff --git a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/tag/resolver/EmptyTagResolver.java b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/tag/resolver/EmptyTagResolver.java index 1c3c9607b3..cc95de6d83 100644 --- a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/tag/resolver/EmptyTagResolver.java +++ b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/tag/resolver/EmptyTagResolver.java @@ -26,6 +26,7 @@ import java.util.Map; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.minimessage.Context; +import net.kyori.adventure.text.minimessage.ParsingException; import net.kyori.adventure.text.minimessage.internal.serializer.ClaimConsumer; import net.kyori.adventure.text.minimessage.internal.serializer.SerializableResolver; import net.kyori.adventure.text.minimessage.tag.Tag; @@ -43,6 +44,11 @@ private EmptyTagResolver() { return null; } + @Override + public @Nullable Tag resolveNamed(final @NotNull String name, final @NotNull NamedArgumentMap arguments, final @NotNull Context ctx) throws ParsingException { + return null; + } + @Override public boolean has(final @NotNull String name) { return false; diff --git a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/tag/resolver/MapTagResolver.java b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/tag/resolver/MapTagResolver.java index e5dbf6a5ce..a99c6a4375 100644 --- a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/tag/resolver/MapTagResolver.java +++ b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/tag/resolver/MapTagResolver.java @@ -25,6 +25,9 @@ import java.util.Map; import java.util.Objects; +import net.kyori.adventure.text.minimessage.Completer; +import net.kyori.adventure.text.minimessage.CompletionContext; +import net.kyori.adventure.text.minimessage.CompletionResult; import net.kyori.adventure.text.minimessage.tag.Tag; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -46,6 +49,11 @@ public boolean has(final @NotNull String name) { return this.tagMap.containsKey(name); } + @Override + public void complete(final @NotNull CompletionContext completionContext, final CompletionResult.@NotNull Builder builder) { + Completer.noArgs(this.tagMap.keySet()).complete(completionContext, builder); + } + @Override public boolean contributeToMap(final @NotNull Map map) { map.putAll(this.tagMap); diff --git a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/tag/resolver/NamedArgumentMap.java b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/tag/resolver/NamedArgumentMap.java new file mode 100644 index 0000000000..ac0e197909 --- /dev/null +++ b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/tag/resolver/NamedArgumentMap.java @@ -0,0 +1,118 @@ +/* + * This file is part of adventure, licensed under the MIT License. + * + * Copyright (c) 2017-2025 KyoriPowered + * + * 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 net.kyori.adventure.text.minimessage.tag.resolver; + +import java.util.function.Supplier; +import net.kyori.adventure.text.minimessage.tag.Tag; +import net.kyori.adventure.util.TriState; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * A map of named {@link Tag} arguments. + * + * @since 4.25.0 + */ +@ApiStatus.NonExtendable +public interface NamedArgumentMap { + + /** + * Get whether an argument of that name exists. + * + * @param name name of the argument or flag + * @return whether an argument by this name is present + * @since 4.25.0 + */ + boolean isPresent(@NotNull String name); + + /** + * Get the number of arguments present. + * + * @return the number of arguments present + * @since 4.25.0 + */ + int size(); + + /** + * Get an argument by its name, returning {@code null} if none was found. + * + * @param name name of the argument + * @return the argument + * @since 4.25.0 + */ + Tag.@Nullable Argument get(@NotNull String name); + + /** + * Get the value of a flag. If a flag is present {@code flag}, + * this method return {@link TriState#TRUE}. If a flag + * is inverted {@code !flag}, {@link TriState#FALSE} is returned. + * Otherwise, {@link TriState#NOT_SET} is returned. + * + * @param name the name of the flag + * @return its presence status in the tag + * @since 4.25.0 + */ + @NotNull TriState flag(@NotNull String name); + + /** + * Get whether this flag is set, inverted or not. + * + * @param name the name of the flag + * @return whether it is present + * @since 4.25.0 + */ + boolean isFlagPresent(@NotNull String name); + + /** + * Get an argument by its name, throwing an exception if no argument with that name was present. + * + * @param name name of the argument + * @return the argument + * @since 4.25.0 + */ + default Tag.@NotNull Argument orThrow(final @NotNull String name) { + return this.orThrow(name, name + " is not present"); + } + + /** + * Get an argument by its name, throwing an exception if no argument with that name was present. + * + * @param name name of the argument + * @param errorMessage the error to throw if an argument with that name is not present + * @return the argument + * @since 4.25.0 + */ + Tag.@NotNull Argument orThrow(@NotNull String name, @NotNull String errorMessage); + + /** + * Get an argument by its name, throwing an exception if no argument with that name was present. + * + * @param name name of the argument + * @param errorMessage the error to throw if an argument with that name is not present + * @return the argument + * @since 4.25.0 + */ + Tag.@NotNull Argument orThrow(@NotNull String name, @NotNull Supplier errorMessage); +} diff --git a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/tag/resolver/SequentialTagResolver.java b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/tag/resolver/SequentialTagResolver.java index 856b716684..aace127f8b 100644 --- a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/tag/resolver/SequentialTagResolver.java +++ b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/tag/resolver/SequentialTagResolver.java @@ -25,6 +25,8 @@ import java.util.Arrays; import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.minimessage.CompletionContext; +import net.kyori.adventure.text.minimessage.CompletionResult; import net.kyori.adventure.text.minimessage.Context; import net.kyori.adventure.text.minimessage.ParsingException; import net.kyori.adventure.text.minimessage.internal.serializer.ClaimConsumer; @@ -40,6 +42,36 @@ final class SequentialTagResolver implements TagResolver, SerializableResolver { this.resolvers = resolvers; } + @Override + public @Nullable Tag resolveNamed(final @NotNull String name, final @NotNull NamedArgumentMap arguments, final @NotNull Context ctx) throws ParsingException { + @Nullable ParsingException thrown = null; + for (final TagResolver resolver : this.resolvers) { + try { + final @Nullable Tag placeholder = resolver.resolveNamed(name, arguments, ctx); + + if (placeholder != null) return placeholder; + } catch (final ParsingException ex) { + if (thrown == null) { + thrown = ex; + } else { + thrown.addSuppressed(ex); + } + } catch (final Exception ex) { + final ParsingException err = ctx.newException("Exception thrown while parsing <" + name + ">", ex, arguments); + if (thrown == null) { + thrown = err; + } else { + thrown.addSuppressed(err); + } + } + } + + if (thrown != null) { + throw thrown; + } + return null; + } + @Override public @Nullable Tag resolve(final @NotNull String name, final @NotNull ArgumentQueue arguments, final @NotNull Context ctx) throws ParsingException { @Nullable ParsingException thrown = null; @@ -83,6 +115,13 @@ public boolean has(final @NotNull String name) { return false; } + @Override + public void complete(final @NotNull CompletionContext completionContext, final CompletionResult.@NotNull Builder builder) { + for (final TagResolver resolver : this.resolvers) { + resolver.complete(completionContext, builder); + } + } + @Override public void handle(final @NotNull Component serializable, final @NotNull ClaimConsumer consumer) { for (final TagResolver resolver : this.resolvers) { diff --git a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/tag/resolver/TagResolver.java b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/tag/resolver/TagResolver.java index c3a3327aa1..a920761a94 100644 --- a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/tag/resolver/TagResolver.java +++ b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/tag/resolver/TagResolver.java @@ -28,8 +28,11 @@ import java.util.HashSet; import java.util.Objects; import java.util.Set; +import java.util.function.BiConsumer; import java.util.function.BiFunction; import java.util.stream.Collector; +import net.kyori.adventure.text.minimessage.CompletionContext; +import net.kyori.adventure.text.minimessage.CompletionResult; import net.kyori.adventure.text.minimessage.Context; import net.kyori.adventure.text.minimessage.ParsingException; import net.kyori.adventure.text.minimessage.internal.TagInternals; @@ -109,7 +112,20 @@ public interface TagResolver { * @since 4.10.0 */ static @NotNull TagResolver resolver(@TagPattern final @NotNull String name, final @NotNull BiFunction handler) { - return resolver(Collections.singleton(name), handler); + return resolver(Collections.singleton(name), handler, null); + } + + /** + * Create a tag resolver that only responds to a single tag name, and whose value does not depend on that name. + * + * @param name the name to respond to + * @param handler the tag handler, may throw {@link ParsingException} if provided arguments are in an invalid format + * @param completionHandler the completion handler + * @return a resolver that creates tags using the provided handler + * @since 123.123.123 + */ + static @NotNull TagResolver resolver(@TagPattern final @NotNull String name, final @NotNull BiFunction handler, final @Nullable BiConsumer completionHandler) { + return resolver(Collections.singleton(name), handler, completionHandler); } /** @@ -121,13 +137,26 @@ public interface TagResolver { * @since 4.10.0 */ static @NotNull TagResolver resolver(final @NotNull Set names, final @NotNull BiFunction handler) { + return resolver(names, handler, null); + } + + /** + * Create a tag resolver that only responds to certain tag names, and whose value does not depend on that name. + * + * @param names the names to respond to + * @param handler the tag handler, may throw {@link ParsingException} if provided arguments are in an invalid format + * @param completionHandler completion handler + * @return a resolver that creates tags using the provided handler + * @since 4.10.0 + */ + static @NotNull TagResolver resolver(final @NotNull Set names, final @NotNull BiFunction handler, final @Nullable BiConsumer completionHandler) { final Set ownNames = new HashSet<>(names); for (final String name : ownNames) { TagInternals.assertValidTagName(name); } requireNonNull(handler, "handler"); - return new TagResolver() { + return new Sequential() { @Override public @Nullable Tag resolve(final @NotNull String name, final @NotNull ArgumentQueue arguments, final @NotNull Context ctx) throws ParsingException { if (!names.contains(name)) return null; @@ -142,6 +171,90 @@ public boolean has(final @NotNull String name) { }; } + /** + * Create a tag resolver that only responds to certain tag names, and whose value does not depend on that name. + * + *

+ * This method creates a special resolver which listens to tags with named arguments instead of sequential ones. + *

+ * + * @param name the name to respond to + * @param handler the tag handler, may throw {@link ParsingException} if provided arguments are in an invalid format + * @return a resolver that creates tags using the provided handler + * @since 4.25.0 + */ + static @NotNull TagResolver namedResolver(final @NotNull String name, final @NotNull BiFunction handler) { + return namedResolver(Collections.singleton(name), handler, null); + } + + /** + * Create a tag resolver that only responds to certain tag names, and whose value does not depend on that name. + * + *

+ * This method creates a special resolver which listens to tags with named arguments instead of sequential ones. + *

+ * + * @param name the name to respond to + * @param handler the tag handler, may throw {@link ParsingException} if provided arguments are in an invalid format + * @param completionHandler the completion handler + * @return a resolver that creates tags using the provided handler + * @since 123.123.123 + */ + static @NotNull TagResolver namedResolver(final @NotNull String name, final @NotNull BiFunction handler, final @NotNull BiConsumer completionHandler) { + return namedResolver(Collections.singleton(name), handler, completionHandler); + } + + /** + * Create a tag resolver that only responds to certain tag names, and whose value does not depend on that name. + * + *

+ * This method creates a special resolver which listens to tags with named arguments instead of sequential ones. + *

+ * + * @param names the names to respond to + * @param handler the tag handler, may throw {@link ParsingException} if provided arguments are in an invalid format + * @return a resolver that creates tags using the provided handler + * @since 4.25.0 + */ + static @NotNull TagResolver namedResolver(final @NotNull Set names, final @NotNull BiFunction handler) { + return namedResolver(names, handler, null); + } + + /** + * Create a tag resolver that only responds to certain tag names, and whose value does not depend on that name. + * + *

+ * This method creates a special resolver which listens to tags with named arguments instead of sequential ones. + *

+ * + * @param names the names to respond to + * @param handler the tag handler, may throw {@link ParsingException} if provided arguments are in an invalid format + * @param completionHandler the completion handler + * @return a resolver that creates tags using the provided handler + * @since 123.123.123 + */ + static @NotNull TagResolver namedResolver(final @NotNull Set names, final @NotNull BiFunction handler, final @Nullable BiConsumer completionHandler) { + final Set ownNames = new HashSet<>(names); + for (final String name : ownNames) { + TagInternals.assertValidTagName(name); + } + requireNonNull(handler, "handler"); + + return new TagResolver.Named() { + @Override + public @Nullable Tag resolveNamed(final @NotNull String name, final @NotNull NamedArgumentMap arguments, final @NotNull Context ctx) throws ParsingException { + if (!names.contains(name)) return null; + + return handler.apply(arguments, ctx); + } + + @Override + public boolean has(final @NotNull String name) { + return names.contains(name); + } + }; + } + /** * Constructs a tag resolver capable of resolving from multiple sources. * @@ -221,7 +334,23 @@ public boolean has(final @NotNull String name) { * @throws ParsingException if the provided arguments are invalid * @since 4.10.0 */ - @Nullable Tag resolve(@TagPattern final @NotNull String name, final @NotNull ArgumentQueue arguments, final @NotNull Context ctx) throws ParsingException; + default @Nullable Tag resolve(@TagPattern final @NotNull String name, final @NotNull ArgumentQueue arguments, final @NotNull Context ctx) throws ParsingException { + return null; + } + + /** + * Gets a tag with named arguments from this resolver based on the current state. + * + * @param name the tag name + * @param arguments the arguments passed to the tag + * @param ctx the parse context + * @return a possible tag + * @throws ParsingException if the provided arguments are invalid + * @since 4.25.0 + */ + default @Nullable Tag resolveNamed(@TagPattern final @NotNull String name, final @NotNull NamedArgumentMap arguments, final @NotNull Context ctx) throws ParsingException { + return null; + } /** * Get whether this resolver handles tags with a certain name. @@ -234,6 +363,17 @@ public boolean has(final @NotNull String name) { */ boolean has(final @NotNull String name); + /** + * Add completions to builder from context. + * + * @param completionContext the context + * @param builder the builder + * @since 123.123.123 + */ + default void complete(final @NotNull CompletionContext completionContext, final CompletionResult.@NotNull Builder builder) { + + } + /** * A resolver that only handles a single tag key. * @@ -280,7 +420,7 @@ default boolean has(final @NotNull String name) { * @since 4.10.0 */ @FunctionalInterface - interface WithoutArguments extends TagResolver { + interface WithoutArguments extends Sequential { /** * Resolve a tag based only on the provided name. * @@ -312,6 +452,26 @@ default boolean has(final @NotNull String name) { } } + /** + * A {@link TagResolver} which only listens to sequential arguments. + * + * @since 4.25.0 + */ + interface Sequential extends TagResolver { + @Override + @Nullable Tag resolve(final @NotNull String name, final @NotNull ArgumentQueue arguments, final @NotNull Context ctx) throws ParsingException; + } + + /** + * A {@link TagResolver} which only listens to named arguments. + * + * @since 4.25.0 + */ + interface Named extends TagResolver { + @Override + @Nullable Tag resolveNamed(final @NotNull String name, final @NotNull NamedArgumentMap arguments, final @NotNull Context ctx) throws ParsingException; + } + /** * A builder to gradually construct tag resolvers. * @@ -339,7 +499,20 @@ interface Builder { * @since 4.10.0 */ default @NotNull Builder tag(@TagPattern final @NotNull String name, final @NotNull BiFunction handler) { - return this.tag(Collections.singleton(name), handler); + return this.tag(Collections.singleton(name), handler, null); + } + + /** + * Add a single dynamically created tag to this resolver. + * + * @param name the name to respond to + * @param handler the tag handler, may throw {@link ParsingException} if provided arguments are in an invalid format + * @param completionHandler the completion handler. + * @return this builder + * @since 123.123.123 + */ + default @NotNull Builder tag(@TagPattern final @NotNull String name, final @NotNull BiFunction handler, final @Nullable BiConsumer completionHandler) { + return this.tag(Collections.singleton(name), handler, completionHandler); } /** @@ -351,7 +524,78 @@ interface Builder { * @since 4.10.0 */ default @NotNull Builder tag(final @NotNull Set names, final @NotNull BiFunction handler) { - return this.resolver(TagResolver.resolver(names, handler)); + return this.tag(names, handler, null); + } + + /** + * Add a single dynamically created tag to this resolver. + * + * @param names the names to respond to + * @param handler the tag handler, may throw {@link ParsingException} if provided arguments are in an invalid format + * @param completionHandler the completion handler. + * @return this builder + * @since 123.123.123 + */ + default @NotNull Builder tag(final @NotNull Set names, final @NotNull BiFunction handler, final @Nullable BiConsumer completionHandler) { + throw new UnsupportedOperationException(); + } + + /** + * Add a single dynamically created tag to this resolver. + * + *

This method adds a special resolver which looks for named arguments instead of sequential arguments.

+ * + * @param name the name to respond to + * @param handler the tag handler, may throw {@link ParsingException} if provided arguments are in an invalid format + * @return this builder + * @since 4.25.0 + */ + default @NotNull Builder namedArgumentsTag(@TagPattern final @NotNull String name, final @NotNull BiFunction handler) { + return this.namedArgumentsTag(Collections.singleton(name), handler, null); + } + + /** + * Add a single dynamically created tag to this resolver. + * + *

This method adds a special resolver which looks for named arguments instead of sequential arguments.

+ * + * @param name the name to respond to + * @param handler the tag handler, may throw {@link ParsingException} if provided arguments are in an invalid format + * @param completionHandler the completion handler. + * @return this builder + * @since 123.123.123 + */ + default @NotNull Builder namedArgumentsTag(@TagPattern final @NotNull String name, final @NotNull BiFunction handler, final @Nullable BiConsumer completionHandler) { + return this.namedArgumentsTag(Collections.singleton(name), handler, completionHandler); + } + + /** + * Add a single dynamically created tag to this resolver. + * + *

This method adds a special resolver which looks for named arguments instead of sequential arguments.

+ * + * @param names the names to respond to + * @param handler the tag handler, may throw {@link ParsingException} if provided arguments are in an invalid format + * @return this builder + * @since 4.25.0 + */ + default @NotNull Builder namedArgumentsTag(final @NotNull Set names, final @NotNull BiFunction handler) { + return this.namedArgumentsTag(names, handler, null); + } + + /** + * Add a single dynamically created tag to this resolver. + * + *

This method adds a special resolver which looks for named arguments instead of sequential arguments.

+ * + * @param names the names to respond to + * @param handler the tag handler, may throw {@link ParsingException} if provided arguments are in an invalid format + * @param completionHandler the completion handler. + * @return this builder + * @since 123.123.123 + */ + default @NotNull Builder namedArgumentsTag(final @NotNull Set names, final @NotNull BiFunction handler, final @Nullable BiConsumer completionHandler) { + return this.resolver(TagResolver.namedResolver(names, handler, completionHandler)); } /** diff --git a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/tag/standard/ClickTag.java b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/tag/standard/ClickTag.java index f81f1cea41..52747dd7cb 100644 --- a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/tag/standard/ClickTag.java +++ b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/tag/standard/ClickTag.java @@ -50,7 +50,14 @@ final class ClickTag { emitter.tag(CLICK) .argument(ClickEvent.Action.NAMES.key(event.action())) .argument(event.value(), QuotingOverride.QUOTED); - }) + }), + (context, builder) -> { + if (context.arguments().isEmpty()) { + ClickEvent.Action.NAMES.keys().stream() + .filter(key -> key.toLowerCase().startsWith(context.partial().toLowerCase())) + .forEach(builder::add); + } + } ); private ClickTag() { diff --git a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/tag/standard/ColorTagResolver.java b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/tag/standard/ColorTagResolver.java index 7cb534b639..9f13803d19 100644 --- a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/tag/standard/ColorTagResolver.java +++ b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/tag/standard/ColorTagResolver.java @@ -43,7 +43,7 @@ * * @since 4.10.0 */ -final class ColorTagResolver implements TagResolver, SerializableResolver.Single { +final class ColorTagResolver implements TagResolver.Sequential, SerializableResolver.Single { private static final String COLOR_3 = "c"; private static final String COLOR_2 = "colour"; private static final String COLOR = "color"; diff --git a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/tag/standard/FontTag.java b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/tag/standard/FontTag.java index 2a191f67b2..13ee05b886 100644 --- a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/tag/standard/FontTag.java +++ b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/tag/standard/FontTag.java @@ -47,7 +47,14 @@ final class FontTag { static final TagResolver RESOLVER = SerializableResolver.claimingStyle( FontTag.FONT, FontTag::create, - StyleClaim.claim(FONT, Style::font, FontTag::emit) + StyleClaim.claim(FONT, Style::font, FontTag::emit), + (context, builder) -> { + if (context.arguments().isEmpty()) { + builder.add(""); + } else if (context.arguments().size() == 1) { + builder.add(""); + } + } ); private FontTag() { diff --git a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/tag/standard/HoverTag.java b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/tag/standard/HoverTag.java index d89e4a30e8..d8cf7421d7 100644 --- a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/tag/standard/HoverTag.java +++ b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/tag/standard/HoverTag.java @@ -30,6 +30,7 @@ import net.kyori.adventure.key.Key; import net.kyori.adventure.nbt.api.BinaryTagHolder; import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.event.ClickEvent; import net.kyori.adventure.text.event.DataComponentValue; import net.kyori.adventure.text.event.HoverEvent; import net.kyori.adventure.text.format.Style; @@ -55,7 +56,15 @@ final class HoverTag { static final TagResolver RESOLVER = SerializableResolver.claimingStyle( HOVER, HoverTag::create, - StyleClaim.claim(HOVER, Style::hoverEvent, HoverTag::emit) + StyleClaim.claim(HOVER, Style::hoverEvent, HoverTag::emit), + (context, builder) -> { + if (context.arguments().isEmpty()) { + HoverEvent.Action.NAMES.keys().stream() + .filter(key -> key.toLowerCase().startsWith(context.partial().toLowerCase())) + .forEach(builder::add); + } + } + ); private HoverTag() { diff --git a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/tag/standard/InsertionTag.java b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/tag/standard/InsertionTag.java index de6b5c2cbe..8de6095007 100644 --- a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/tag/standard/InsertionTag.java +++ b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/tag/standard/InsertionTag.java @@ -23,6 +23,7 @@ */ package net.kyori.adventure.text.minimessage.tag.standard; +import net.kyori.adventure.text.event.ClickEvent; import net.kyori.adventure.text.format.Style; import net.kyori.adventure.text.minimessage.Context; import net.kyori.adventure.text.minimessage.ParsingException; diff --git a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/tag/standard/ShadowColorTag.java b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/tag/standard/ShadowColorTag.java index 5211da0c07..091a863087 100644 --- a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/tag/standard/ShadowColorTag.java +++ b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/tag/standard/ShadowColorTag.java @@ -47,7 +47,14 @@ final class ShadowColorTag { SerializableResolver.claimingStyle( SHADOW_COLOR, ShadowColorTag::create, - StyleClaim.claim(SHADOW_COLOR, Style::shadowColor, ShadowColorTag::emit) + StyleClaim.claim(SHADOW_COLOR, Style::shadowColor, ShadowColorTag::emit), + (context, builder) -> { + if (context.arguments().isEmpty()) { + builder.add("#RRGGBBAA"); + } else if (context.arguments().size() == 1) { + builder.add("0.25"); + } + } ), TagResolver.resolver(SHADOW_NONE, Tag.styling(ShadowColor.none())) ); diff --git a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/translation/ArgumentTag.java b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/translation/ArgumentTag.java index 8b466d7a9a..cd1d4887dd 100644 --- a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/translation/ArgumentTag.java +++ b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/translation/ArgumentTag.java @@ -32,7 +32,7 @@ import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -final class ArgumentTag implements TagResolver { +final class ArgumentTag implements TagResolver.Sequential { private static final String NAME = "argument"; private static final String NAME_1 = "arg"; diff --git a/text-minimessage/src/test/java/net/kyori/adventure/text/minimessage/MiniMessageNamedArgumentsTest.java b/text-minimessage/src/test/java/net/kyori/adventure/text/minimessage/MiniMessageNamedArgumentsTest.java new file mode 100644 index 0000000000..b114e097b9 --- /dev/null +++ b/text-minimessage/src/test/java/net/kyori/adventure/text/minimessage/MiniMessageNamedArgumentsTest.java @@ -0,0 +1,238 @@ +/* + * This file is part of adventure, licensed under the MIT License. + * + * Copyright (c) 2017-2025 KyoriPowered + * + * 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 net.kyori.adventure.text.minimessage; + +import java.util.ArrayList; +import java.util.List; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.TextColor; +import net.kyori.adventure.text.minimessage.tag.Tag; +import net.kyori.adventure.text.minimessage.tag.resolver.TagResolver; +import net.kyori.adventure.util.TriState; +import org.junit.jupiter.api.Test; + +import static net.kyori.adventure.text.Component.text; +import static net.kyori.adventure.text.format.NamedTextColor.BLUE; +import static net.kyori.adventure.text.format.NamedTextColor.RED; +import static net.kyori.adventure.text.format.TextDecoration.BOLD; +import static net.kyori.adventure.text.format.TextDecoration.ITALIC; + +public class MiniMessageNamedArgumentsTest extends AbstractTest { + + private static final TagResolver INSERT_VALUE_RESOLVER = TagResolver.namedResolver("insert", (args, ctx) -> Tag.selfClosingInserting( + text(args.orThrow("value", "value is missing").value()) + )); + + @Test + void testBasicInsertingNamedTagArguments() { + final String input = ""; + final Component expected = text("twentyfive"); + assertParsedEquals(MiniMessage.miniMessage(), expected, input, INSERT_VALUE_RESOLVER); + } + + @Test + void testWithColorNesting() { + final String input = "This is text!"; + final Component expected = text() + .color(RED) + .append(text("This is ")) + .append(text("some", BLUE)) + .append(text(" text!")) + .build(); + + assertParsedEquals(MiniMessage.miniMessage(), expected, input, INSERT_VALUE_RESOLVER); + } + + @Test + void testMultipleArguments() { + final String input = ""; + final Component expected = text("Hello, World Hello, World Hello, World Hello, World Hello, World"); + + assertParsedEquals(MiniMessage.miniMessage(), expected, input, TagResolver.namedResolver("repeat", + (args, ctx) -> { + final int amount = args.isPresent("amount") ? args.get("amount").asInt().getAsInt() : 1; + final String text = args.orThrow("text", "text is missing").value(); + final StringBuilder builder = new StringBuilder(); + for (int i = 0; i < amount; i++) { + builder.append(text); + if (i + 1 < amount) { + builder.append(" "); + } + } + return Tag.selfClosingInserting(text(builder.toString())); + }) + ); + } + + @Test + void testComplexArguments() { + final String input = "This is orange, bold, and italic styled text!"; + final Component expected = text() + .append(text("This is ")) + .append(text("orange, bold, and italic styled", TextColor.color(0xffaa00), BOLD, ITALIC)) + .append(text(" text!")) + .build(); + + assertParsedEquals(MiniMessage.miniMessage(), expected, input, TagResolver.namedResolver("styled", + (args, ctx) -> Tag.styling(builder -> { + if (args.isPresent("color")) { + builder.color(TextColor.fromCSSHexString(args.orThrow("color", "color is missing").value())); + } + + if (args.isPresent("bold")) { + builder.decorate(BOLD); + } + + if (args.isPresent("italic")) { + builder.decorate(ITALIC); + } + })) + ); + } + + @Test + void testWithExtraWhitespace() { + final String input = ""; + final Component expected = text("too much?"); + assertParsedEquals(MiniMessage.miniMessage(), expected, input, INSERT_VALUE_RESOLVER); + } + + @Test + void testWithQueuedAndExtraWhitespace() { + final String input = "This tag does not count, this does not either, but the red one does!"; + final Component expected = text() + .append(text("This tag does not count, this does not either, but the ")) + .append(text("red one does!", RED)) + .build(); + assertParsedEquals(MiniMessage + .builder() + .debug(System.out::print) + .build(), expected, input, INSERT_VALUE_RESOLVER); + } + + @Test + void testWhitespaceBeforeQueued() { + final String input = ""; + final Component expected = text(""); + assertParsedEquals(expected, input); + } + + @Test + void testQueuedTreatedAsNamed() { + final String input = ""; + final Component expected = text(""); + assertParsedEquals(expected, input); + } + + @Test + void testNamedTreatedAsQueued() { + final String input = ""; + final Component expected = text(""); + assertParsedEquals(MiniMessage.miniMessage(), expected, input, TagResolver.namedResolver("named", (args, ctx) -> Tag.inserting(text("wrong!")))); + } + + @Test + void testNoArgsAlwaysTreatedAsQueued() { + final String input = ""; + final Component expected = text("pass"); + assertParsedEquals(MiniMessage.miniMessage(), expected, input, + TagResolver.namedResolver("test", (args, ctx) -> Tag.inserting(text("fail"))), + TagResolver.resolver("test", (args, ctx) -> Tag.inserting(text("pass"))) + ); + } + + @Test + void testNamedQueuedCanCoexist() { + final String input = " "; + final Component expected = text("Hello World!"); + assertParsedEquals(MiniMessage.miniMessage(), expected, input, + TagResolver.resolver("test", (args, ctx) -> Tag.inserting(text("Hello"))), + TagResolver.namedResolver("test", (args, ctx) -> Tag.inserting(text("World!"))) + ); + } + + @Test + void testArgumentlessNamedTag() { + final String input = ""; + final Component expected = text("Works!"); + assertParsedEquals(MiniMessage.miniMessage(), expected, input, TagResolver.namedResolver( + "no_args", (args, ctx) -> Tag.inserting(text("Works!")) + )); + } + + @Test + void testUrlInNamedArgs() { + final String input = ""; + final Component expected = text("https://github.com/KyoriPowered/adventure"); + assertParsedEquals(MiniMessage.miniMessage(), expected, input, INSERT_VALUE_RESOLVER); + } + + @Test + void testABunchOfMoreSymbolsAreArguments() { + final String input = ""; // The last / is interpreted as an explicit self-closing tag. + final Component expected = text("H%%^Is@@cool;;:/"); + assertParsedEquals(MiniMessage.miniMessage(), expected, input, INSERT_VALUE_RESOLVER); + } + + @Test + void testStringValue() { + final String input = ""; + final Component expected = text("This is great =)"); + assertParsedEquals(MiniMessage.miniMessage(), expected, input, INSERT_VALUE_RESOLVER); + } + + @Test + void testInvertedFlags() { + final String input = " !"; + final Component expected = text("Adventure is very cool!"); + assertParsedEquals(MiniMessage.miniMessage(), expected, input, TagResolver.namedResolver( + "test", (args, ctx) -> { + final List strings = new ArrayList<>(); + final TriState flag = args.flag("flag"); + final TriState otherFlag = args.flag("other_flag"); + + if (flag == TriState.TRUE) { + strings.add("Adventure"); + } else if (flag == TriState.FALSE) { + strings.add("very"); + } + + if (otherFlag == TriState.TRUE) { + strings.add("is"); + } else if (otherFlag == TriState.FALSE) { + strings.add("cool"); + } + + return Tag.selfClosingInserting(text(String.join(" ", strings))); + } + )); + } + + @Test + void testWhitespaceAroundEquals() { + final String input = ""; + final Component expected = text(""); + assertParsedEquals(MiniMessage.miniMessage(), expected, input, INSERT_VALUE_RESOLVER); + } +} diff --git a/text-minimessage/src/test/java/net/kyori/adventure/text/minimessage/MiniMessageParserTest.java b/text-minimessage/src/test/java/net/kyori/adventure/text/minimessage/MiniMessageParserTest.java index 43f24fed29..faa46d9af6 100644 --- a/text-minimessage/src/test/java/net/kyori/adventure/text/minimessage/MiniMessageParserTest.java +++ b/text-minimessage/src/test/java/net/kyori/adventure/text/minimessage/MiniMessageParserTest.java @@ -23,6 +23,7 @@ */ package net.kyori.adventure.text.minimessage; +import java.util.ArrayList; import java.util.Collections; import java.util.List; import net.kyori.adventure.text.Component; @@ -33,6 +34,7 @@ import net.kyori.adventure.text.minimessage.internal.parser.TokenType; import net.kyori.adventure.text.minimessage.tag.Tag; import net.kyori.adventure.text.minimessage.tag.resolver.ArgumentQueue; +import net.kyori.adventure.text.minimessage.tag.resolver.NamedArgumentMap; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; import net.kyori.adventure.text.minimessage.tag.resolver.TagResolver; import net.kyori.adventure.text.minimessage.tree.Node; @@ -330,6 +332,45 @@ void testEscapeIncompleteTags() { this.assertParsedEquals(expected, escaped); } + @Test + void testNamedArgumentsTokens() { + final String basicInput = ""; + final List expectedTokensBasicInput = Collections.singletonList(new Token(0, basicInput.length(), TokenType.OPEN_TAG)); + assertIterableEquals(expectedTokensBasicInput, TokenParser.tokenize(basicInput, false)); + + final int toggleLength = "toggle".length(); + + final String booleanToggleInput = ""; + final List expectedTokensBooleanToggleInput = new ArrayList<>(); + final Token parentToken = new Token(0, booleanToggleInput.length(), TokenType.OPEN_TAG); + parentToken.childTokens(new ArrayList<>()); + parentToken.childTokens().add(new Token(0, toggleLength, TokenType.TEXT)); + parentToken.childTokens().add(new Token(toggleLength + 2, "enabled".length(), TokenType.TAG_VALUE_TOGGLE)); + expectedTokensBooleanToggleInput.add(parentToken); + assertIterableEquals(expectedTokensBooleanToggleInput, TokenParser.tokenize(booleanToggleInput, false)); + + final String namedArgumentInput = ""; + final List expectedTokensNamedArgumentInput = new ArrayList<>(); + final Token parentTokenNamed = new Token(0, namedArgumentInput.length(), TokenType.OPEN_TAG); + parentTokenNamed.childTokens(new ArrayList<>()); + parentTokenNamed.childTokens().add(new Token(0, 1, TokenType.TEXT)); + parentTokenNamed.childTokens().add(new Token(2, 1, TokenType.TAG_VALUE_NAME)); + parentTokenNamed.childTokens().add(new Token(4, 1, TokenType.TAG_VALUE)); + expectedTokensNamedArgumentInput.add(parentTokenNamed); + assertIterableEquals(expectedTokensNamedArgumentInput, TokenParser.tokenize(namedArgumentInput, false)); + + final String mixedArgumentInput = ""; + final List expectedTokensMixedArgumentInput = new ArrayList<>(); + final Token parentTokenMixed = new Token(0, mixedArgumentInput.length(), TokenType.OPEN_TAG); + parentTokenMixed.childTokens(new ArrayList<>()); + parentTokenMixed.childTokens().add(new Token(0, 1, TokenType.TEXT)); + parentTokenMixed.childTokens().add(new Token(2, 1, TokenType.TAG_VALUE_NAME)); + parentTokenMixed.childTokens().add(new Token(4, 1, TokenType.TAG_VALUE)); + parentTokenMixed.childTokens().add(new Token(6, toggleLength, TokenType.TAG_VALUE_TOGGLE)); + expectedTokensMixedArgumentInput.add(parentTokenMixed); + assertIterableEquals(expectedTokensMixedArgumentInput, TokenParser.tokenize(mixedArgumentInput, false)); + } + // GH-68, GH-93 @Test void testAngleBracketsShit() { @@ -379,9 +420,9 @@ void testEscapesEscapablePlainText() { void testEscapeInsideOfContext() { final String input = "Test"; final Component expected = text() - .content("Test") - .hoverEvent(text("Look at\\ this '")) - .build(); + .content("Test") + .hoverEvent(text("Look at\\ this '")) + .build(); this.assertParsedEquals(expected, input); } @@ -530,19 +571,7 @@ void testValidTagNames() { void invalidPreprocessTagNames() { final String input = "Some<##>of<>theseare<3 >tags"; final Component expected = Component.text("Some<##>of<>these(meow)are<3 >tags"); - final TagResolver alwaysMatchingResolver = new TagResolver() { - @Override - public Tag resolve(final @NotNull String name, final @NotNull ArgumentQueue arguments, final @NotNull Context ctx) throws ParsingException { - return Tag.preProcessParsed("(meow)"); - } - - @Override - public boolean has(final @NotNull String name) { - return true; - } - }; - - this.assertParsedEquals(expected, input, alwaysMatchingResolver); + this.assertParsedEquals(expected, input, new AlwaysMatchingResolver()); } // https://github.com/KyoriPowered/adventure/issues/1011 @@ -553,4 +582,21 @@ void testNonTerminatingQuoteArgument() { this.assertParsedEquals(expected, input); } + + private static final class AlwaysMatchingResolver implements TagResolver.Sequential, TagResolver.Named { + @Override + public @NotNull Tag resolve(final @NotNull String name, final @NotNull ArgumentQueue arguments, final @NotNull Context ctx) throws ParsingException { + return Tag.preProcessParsed("(meow)"); + } + + @Override + public @NotNull Tag resolveNamed(final @NotNull String name, final @NotNull NamedArgumentMap arguments, final @NotNull Context ctx) throws ParsingException { + return Tag.preProcessParsed("(meow)"); + } + + @Override + public boolean has(final @NotNull String name) { + return true; + } + } } diff --git a/text-minimessage/src/test/java/net/kyori/adventure/text/minimessage/MiniMessageTest.java b/text-minimessage/src/test/java/net/kyori/adventure/text/minimessage/MiniMessageTest.java index 6ec851ae02..1cbfc5ce65 100644 --- a/text-minimessage/src/test/java/net/kyori/adventure/text/minimessage/MiniMessageTest.java +++ b/text-minimessage/src/test/java/net/kyori/adventure/text/minimessage/MiniMessageTest.java @@ -372,7 +372,7 @@ void debugModeSimple() { final List messages = Arrays.asList(sb.toString().split("\n")); assertTrue(messages.contains("Beginning parsing message RED ")); - assertTrue(messages.contains("Attempting to match node 'red' at column 0")); + assertTrue(messages.contains("Attempting to match node as sequential 'red' at column 0")); assertTrue(anyMatch(messages, it -> it.startsWith("Successfully matched node 'red' to tag "))); assertTrue(messages.contains("Text parsed into element tree:")); assertTrue(messages.contains("Node {")); @@ -391,11 +391,11 @@ void debugModeMoreComplex() { final List messages = Arrays.asList(sb.toString().split("\n")); assertTrue(messages.contains("Beginning parsing message RED BLUE bad click ")); - assertTrue(messages.contains("Attempting to match node 'red' at column 0")); + assertTrue(messages.contains("Attempting to match node as sequential 'red' at column 0")); assertTrue(anyMatch(messages, it -> it.startsWith("Successfully matched node 'red' to tag "))); - assertTrue(messages.contains("Attempting to match node 'blue' at column 10")); + assertTrue(messages.contains("Attempting to match node as sequential 'blue' at column 10")); assertTrue(anyMatch(messages, it -> it.startsWith("Successfully matched node 'blue' to tag "))); - assertTrue(messages.contains("Attempting to match node 'click' at column 22")); + assertTrue(messages.contains("Attempting to match node as sequential 'click' at column 22")); assertTrue(messages.contains("Could not match node 'click' - A click tag requires an action of one of [run_command, open_file, custom, open_url, copy_to_clipboard, change_page, show_dialog, suggest_command]")); assertTrue(messages.contains("\t RED BLUE bad click ")); assertTrue(messages.contains("\t ^~~~~~^")); @@ -419,11 +419,11 @@ void debugModeMoreComplexNoError() { final List messages = Arrays.asList(sb.toString().split("\n")); assertTrue(messages.contains("Beginning parsing message RED BLUE good click ")); - assertTrue(messages.contains("Attempting to match node 'red' at column 0")); + assertTrue(messages.contains("Attempting to match node as sequential 'red' at column 0")); assertTrue(anyMatch(messages, it -> it.startsWith("Successfully matched node 'red' to tag "))); - assertTrue(messages.contains("Attempting to match node 'blue' at column 10")); + assertTrue(messages.contains("Attempting to match node as sequential 'blue' at column 10")); assertTrue(anyMatch(messages, it -> it.startsWith("Successfully matched node 'blue' to tag "))); - assertTrue(messages.contains("Attempting to match node 'click' at column 22")); + assertTrue(messages.contains("Attempting to match node as sequential 'click' at column 22")); assertTrue(anyMatch(messages, it -> it.startsWith("Successfully matched node 'click' to tag "))); assertTrue(messages.contains("Text parsed into element tree:")); assertTrue(messages.contains("Node {")); @@ -439,6 +439,38 @@ void debugModeMoreComplexNoError() { assertTrue(messages.contains("}")); } + @Test + void debugNamedArguments() { + final String input = " I have a red text!"; + + final StringBuilder sb = new StringBuilder(); + MiniMessage.builder() + .tags(TagResolver.resolver( + // At the time of writing, the tag did not yet exist. + TagResolver.namedResolver("head", (args, ctx) -> Tag.selfClosingInserting(Component.text("dummy"))), + TagResolver.standard() + )).debug(sb::append).build().deserialize(input); + final List messages = Arrays.asList(sb.toString().split("\n")); + + assertTrue(messages.contains("Beginning parsing message I have a red text!")); + assertTrue(messages.contains("Attempting to match node as sequential 'red' at column 0")); + assertTrue(anyMatch(messages, it -> it.startsWith("Successfully matched node 'red' to tag "))); + assertTrue(messages.contains("Attempting to match node as named 'head' at column 0")); + assertTrue(anyMatch(messages, it -> it.startsWith("Successfully matched node 'head' to tag "))); + assertTrue(messages.contains("Attempting to match node as sequential 'red' at column 52")); + assertTrue(anyMatch(messages, it -> it.startsWith("Successfully matched node 'red' to tag "))); + assertTrue(messages.contains("Text parsed into element tree:")); + assertTrue(messages.contains("Node {")); + assertTrue(messages.contains(" TagNode('head', 'name', 'Strokkur24', 'disable_outer_layer') {")); + assertTrue(messages.contains(" }")); + assertTrue(messages.contains(" TextNode(' I have a ')")); + assertTrue(messages.contains(" TagNode('red') {")); + assertTrue(messages.contains(" TextNode('red')")); + assertTrue(messages.contains(" }")); + assertTrue(messages.contains(" TextNode(' text!')")); + assertTrue(messages.contains("}")); + } + static class TestTarget1 implements Pointered { public String data; }