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/MiniMessageParser.java b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/MiniMessageParser.java index 45742289b8..4fa366162f 100644 --- a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/MiniMessageParser.java +++ b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/MiniMessageParser.java @@ -135,11 +135,13 @@ private void processTokens(final @NotNull StringBuilder sb, final @NotNull Strin debug.accept("\n"); } - final TokenParser.TagProvider transformationFactory; + final TokenParser.SequentialTagProvider sequentialTagProvider; + final TokenParser.NamedTagProvider namedTagProvider; + if (debug != null) { - transformationFactory = (name, args, token) -> { + 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/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 eabbf1b09f..d3ebec838d 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,33 @@ 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 + */ + @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 */ @ApiStatus.Internal - public interface TagProvider { + public interface NamedTagProvider { /** * Look up a tag. * @@ -676,17 +772,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 +865,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 +924,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..fa675cddce --- /dev/null +++ b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/internal/serializer/NamedComponentClaimingResolverImpl.java @@ -0,0 +1,65 @@ +/* + * 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.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; + + NamedComponentClaimingResolverImpl(final Set names, final BiFunction handler, final Function componentClaim) { + this.names = names; + this.handler = handler; + this.componentClaim = componentClaim; + } + + @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 @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..8b76e7f899 --- /dev/null +++ b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/internal/serializer/NamedStyleClaimingResolverImpl.java @@ -0,0 +1,63 @@ +/* + * 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.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 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 89% 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..7dd74d086d 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 @@ -35,12 +35,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; 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 89% 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..2d76db8a33 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 @@ -33,12 +33,12 @@ 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; - 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) { this.names = names; this.handler = handler; this.styleClaim = 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..f12b125328 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 @@ -34,6 +34,7 @@ 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 +60,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 sequenced 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 +90,27 @@ 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 sequenced 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); } /** @@ -90,6 +126,21 @@ 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. + * + *

The resolver created is a special resolver which listens to named arguments instead of sequenced 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 +156,27 @@ public interface SerializableResolver { TagInternals.assertValidTagName(name); } requireNonNull(handler, "handler"); - return new StyleClaimingResolverImpl(ownNames, handler, styleClaim); + return new SequentialStyleClaimingResolverImpl(ownNames, handler, styleClaim); + } + + /** + * 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 sequenced 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/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/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 ae7e5a0456..ad9e03d187 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 @@ -40,10 +40,48 @@ 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) { + if (!(resolver instanceof Named)) { + continue; + } + + 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; for (final TagResolver resolver : this.resolvers) { + if (!(resolver instanceof Sequential)) { + continue; + } + try { final @Nullable Tag placeholder = resolver.resolve(name, arguments, ctx); 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..883d99c573 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 @@ -127,7 +127,7 @@ public interface TagResolver { } 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 +142,56 @@ 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 sequences 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); + } + + /** + * 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 sequences 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) { + 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. * @@ -223,6 +273,18 @@ public boolean has(final @NotNull String name) { */ @Nullable Tag resolve(@TagPattern final @NotNull String name, final @NotNull ArgumentQueue arguments, final @NotNull Context ctx) throws ParsingException; + /** + * 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 + */ + @Nullable Tag resolveNamed(@TagPattern final @NotNull String name, final @NotNull NamedArgumentMap arguments, final @NotNull Context ctx) throws ParsingException; + /** * Get whether this resolver handles tags with a certain name. * @@ -280,7 +342,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 +374,30 @@ 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 + default @Nullable Tag resolveNamed(final @NotNull String name, final @NotNull NamedArgumentMap arguments, final @NotNull Context ctx) throws ParsingException { + return null; + } + } + + /** + * A {@link TagResolver} which only listens to named arguments. + * + * @since 4.25.0 + */ + interface Named extends TagResolver { + @Override + default @Nullable Tag resolve(final @NotNull String name, final @NotNull ArgumentQueue arguments, final @NotNull Context ctx) throws ParsingException { + return null; + } + } + /** * A builder to gradually construct tag resolvers. * @@ -354,6 +440,34 @@ interface Builder { return this.resolver(TagResolver.resolver(names, handler)); } + /** + * 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); + } + + /** + * 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.resolver(TagResolver.namedResolver(names, handler)); + } + /** * Add a placeholder resolver to those queried by the result of this builder. * 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/StandardTags.java b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/tag/standard/StandardTags.java index d5e44841bc..17c785a2ce 100644 --- a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/tag/standard/StandardTags.java +++ b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/tag/standard/StandardTags.java @@ -65,7 +65,8 @@ private StandardTags() { ScoreTag.RESOLVER, NbtTag.RESOLVER, PrideTag.RESOLVER, - ShadowColorTag.RESOLVER + ShadowColorTag.RESOLVER, + StyleTag.RESOLVER ) .build(); @@ -288,6 +289,16 @@ public static TagResolver transition() { return ShadowColorTag.RESOLVER; } + /** + * Get a resolver for the {@value StyleTag#STYLE} tags. + * + * @return a resolver for the {@value StyleTag#STYLE} tags + * @since 4.25.0 + */ + public static @NotNull TagResolver style() { + return StyleTag.RESOLVER; + } + /** * Get a resolver that handles all default standard tags. * diff --git a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/tag/standard/StyleTag.java b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/tag/standard/StyleTag.java new file mode 100644 index 0000000000..5ee3943455 --- /dev/null +++ b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/tag/standard/StyleTag.java @@ -0,0 +1,108 @@ +/* + * 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.standard; + +import net.kyori.adventure.text.format.ShadowColor; +import net.kyori.adventure.text.format.TextColor; +import net.kyori.adventure.text.format.TextDecoration; +import net.kyori.adventure.text.minimessage.Context; +import net.kyori.adventure.text.minimessage.ParsingException; +import net.kyori.adventure.text.minimessage.internal.serializer.SerializableResolver; +import net.kyori.adventure.text.minimessage.internal.serializer.StyleClaim; +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 net.kyori.adventure.util.TriState; +import org.intellij.lang.annotations.Subst; +import org.jetbrains.annotations.Nullable; + +/** + * A style tag for setting multiple styles. + * + * @since 4.25.0 + */ +final class StyleTag { + private static final String STYLE = "style"; + + static final TagResolver RESOLVER = SerializableResolver.claimingStyleNamed( + STYLE, + StyleTag::create, + StyleClaim.claim( + "style_tag", + style -> style, + style -> false, // this tag should never be emitted + (style, emitter) -> {} + ) + ); + + private StyleTag() { + } + + static Tag create(final NamedArgumentMap args, final Context ctx) throws ParsingException { + final TriState bold = args.isFlagPresent("bold") ? args.flag("bold") : args.flag("b"); + final TriState italic = args.isFlagPresent("italic") ? args.flag("italic") : args.flag("i"); + final TriState underlined = args.isFlagPresent("underlined") ? args.flag("underlined") : args.flag("u"); + final TriState obfuscated = args.isFlagPresent("obfuscated") ? args.flag("obfuscated") : args.flag("obf"); + final TriState strikethrough = args.isFlagPresent("strikethrough") ? args.flag("strikethrough") : args.flag("st"); + + final Tag.@Nullable Argument colorArgument = args.isPresent("color") ? args.get("color") : args.get("c"); + final TextColor color; + if (colorArgument != null) { + color = TextColor.fromCSSHexString(colorArgument.value()); + if (color == null) { + throw ctx.newException("Color '" + colorArgument.value() + "' is in an invalid format. Please use #RRGGBB or #RGB."); + } + } else { + color = null; + } + + final Tag.@Nullable Argument shadowArgument = args.isPresent("shadow") ? args.get("shadow") : args.get("s"); + final ShadowColor shadow; + if (shadowArgument != null) { + final @Subst("#00000000") String value = shadowArgument.value(); + shadow = ShadowColor.fromHexString(value); + if (shadow == null) { + throw ctx.newException("Shadow color '" + value + "' is in an invalid format. Please use #RRGGBBAA."); + } + } else { + shadow = null; + } + + return Tag.styling(builder -> { + builder.decoration(TextDecoration.BOLD, TextDecoration.State.byTriState(bold)); + builder.decoration(TextDecoration.ITALIC, TextDecoration.State.byTriState(italic)); + builder.decoration(TextDecoration.UNDERLINED, TextDecoration.State.byTriState(underlined)); + builder.decoration(TextDecoration.OBFUSCATED, TextDecoration.State.byTriState(obfuscated)); + builder.decoration(TextDecoration.STRIKETHROUGH, TextDecoration.State.byTriState(strikethrough)); + + if (color != null) { + builder.color(color); + } + + if (shadow != null) { + builder.shadowColor(shadow); + } + }); + } +} 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..a9182ce495 --- /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; } diff --git a/text-minimessage/src/test/java/net/kyori/adventure/text/minimessage/tag/standard/StyleTagTest.java b/text-minimessage/src/test/java/net/kyori/adventure/text/minimessage/tag/standard/StyleTagTest.java new file mode 100644 index 0000000000..0a3043b91a --- /dev/null +++ b/text-minimessage/src/test/java/net/kyori/adventure/text/minimessage/tag/standard/StyleTagTest.java @@ -0,0 +1,78 @@ +/* + * 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.standard; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.ShadowColor; +import net.kyori.adventure.text.format.Style; +import net.kyori.adventure.text.format.TextColor; +import net.kyori.adventure.text.format.TextDecoration; +import net.kyori.adventure.text.minimessage.AbstractTest; +import org.junit.jupiter.api.Test; + +import static net.kyori.adventure.text.Component.text; + +public class StyleTagTest extends AbstractTest { + + @Test + void testEmptyTag() { + final String input = ""; + final Component expected = text("Hey there!"); + assertParsedEquals(expected, input); + } + + @Test + void testBasic() { + final String input = ""; + final Component expected = text("Fancy", TextColor.fromCSSHexString("#f00"), TextDecoration.BOLD, TextDecoration.ITALIC, TextDecoration.UNDERLINED); + assertParsedEquals(expected, input); + } + + @Test + void testInversion() { + final String input = "Bold, , bold!"; + final Component expected = text() + .decorate(TextDecoration.BOLD) + .append(text("Bold, ")) + .append(text("not bold", Style.style(b -> b.decoration(TextDecoration.BOLD, TextDecoration.State.FALSE)))) + .append(text(", bold!")) + .build(); + assertParsedEquals(expected, input); + } + + @Test + void testPrecedence() { + final String input = "