diff --git a/text-minimessage/src/jmh/java/net/kyori/adventure/text/minimessage/benchmark/MiniMessageBenchmark.java b/text-minimessage/src/jmh/java/net/kyori/adventure/text/minimessage/benchmark/MiniMessageBenchmark.java index 751069219a..4cf3c5b445 100644 --- a/text-minimessage/src/jmh/java/net/kyori/adventure/text/minimessage/benchmark/MiniMessageBenchmark.java +++ b/text-minimessage/src/jmh/java/net/kyori/adventure/text/minimessage/benchmark/MiniMessageBenchmark.java @@ -23,11 +23,14 @@ */ package net.kyori.adventure.text.minimessage.benchmark; +import java.util.List; import java.util.concurrent.TimeUnit; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.format.NamedTextColor; import net.kyori.adventure.text.format.TextColor; import net.kyori.adventure.text.minimessage.MiniMessage; +import net.kyori.adventure.text.minimessage.internal.parser.Token; +import net.kyori.adventure.text.minimessage.internal.parser.TokenParser; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; import org.openjdk.jmh.annotations.Benchmark; import org.openjdk.jmh.annotations.BenchmarkMode; @@ -35,9 +38,9 @@ import org.openjdk.jmh.annotations.Mode; import org.openjdk.jmh.annotations.OutputTimeUnit; -@Fork(value = 1, warmups = 1) +@Fork(value = 2, warmups = 5) @BenchmarkMode(Mode.SampleTime) -@OutputTimeUnit(TimeUnit.MICROSECONDS) +@OutputTimeUnit(TimeUnit.NANOSECONDS) public class MiniMessageBenchmark { private static final Component MANY_COLORS = Component.textOfChildren( Component.text("red", NamedTextColor.RED), @@ -47,6 +50,12 @@ public class MiniMessageBenchmark { Component.text("another", TextColor.color(0xf6a6a6)) ); + @Benchmark + public List testTokenization() { + final String input = "A very cool but also some what complex and crazy (click here for cool video) MiniMessage String."; + return TokenParser.tokenize(input, true); + } + @Benchmark public Component testNiceMix() { final String input = " random strangerclick here to FEEL it"; diff --git a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/ArgumentQueueImpl.java b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/ArgumentQueueImpl.java index a1f59deca5..004a8f972b 100644 --- a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/ArgumentQueueImpl.java +++ b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/ArgumentQueueImpl.java @@ -23,62 +23,69 @@ */ package net.kyori.adventure.text.minimessage; -import java.util.List; import java.util.function.Supplier; +import net.kyori.adventure.text.minimessage.internal.util.ListMapHolder; import net.kyori.adventure.text.minimessage.tag.Tag; import net.kyori.adventure.text.minimessage.tag.resolver.ArgumentQueue; +import net.kyori.adventure.util.TriState; +import org.jspecify.annotations.NonNull; import org.jspecify.annotations.Nullable; import static java.util.Objects.requireNonNull; +/* + * Note to anyone looking at this class and wondering about the {@link NonNull}s. For + * some reason, IntelliJ completely ignores any {@link NullMarked} annotations on the package + * or on the class, so these pop methods had to be annotated explicitly ¯\_(ツ)_/¯. + */ final class ArgumentQueueImpl implements ArgumentQueue { private final Context context; - private final List args; + private final ListMapHolder args; private int ptr = 0; - ArgumentQueueImpl(final Context context, final List args) { + ArgumentQueueImpl(final Context context, final ListMapHolder args) { this.context = context; this.args = args; } - public List args() { + public ListMapHolder args() { return this.args; } @Override - public T pop() { + public @NonNull T pop() { if (!this.hasNext()) { throw this.context.newException("Missing argument for this tag!", this); } - return this.args.get(this.ptr++); + return this.args.list().get(this.ptr++); } @Override - public T popOr(final String errorMessage) { + public @NonNull T popOr(final String errorMessage) { requireNonNull(errorMessage, "errorMessage"); if (!this.hasNext()) { throw this.context.newException(errorMessage, this); } - return this.args.get(this.ptr++); + return this.args.list().get(this.ptr++); } @Override - public T popOr(final Supplier errorMessage) { + public @NonNull T popOr(final Supplier errorMessage) { requireNonNull(errorMessage, "errorMessage"); if (!this.hasNext()) { throw this.context.newException(requireNonNull(errorMessage.get(), "errorMessage.get()"), this); } - return this.args.get(this.ptr++); + return this.args.list().get(this.ptr++); } @Override public @Nullable T peek() { - return this.hasNext() ? this.args.get(this.ptr) : null; + return this.hasNext() ? this.args.list().get(this.ptr) : null; } @Override public boolean hasNext() { - return this.ptr < this.args.size(); + return this.ptr < this.args.list().size(); } @Override @@ -90,4 +97,60 @@ public void reset() { public String toString() { return this.args.toString(); } + + @Override + public boolean isPresent(final String name) { + requireNonNull(name, "name"); + return this.args.map().containsKey(name); + } + + @Override + public Tag.@Nullable Argument get(final String name) { + requireNonNull(name, "name"); + return this.args.map().get(name); + } + + @Override + public TriState flag(final 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 String name) { + if (this.isPresent(name)) { + return true; + } + return this.isPresent('!' + name); + } + + @Override + public Tag.Argument orThrow(final String name, final 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.Argument orThrow(final String name, final 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/ContextImpl.java b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/ContextImpl.java index 8a54774f57..ef642c556b 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 @@ -23,6 +23,7 @@ */ package net.kyori.adventure.text.minimessage; +import java.util.ArrayList; import java.util.List; import java.util.function.Consumer; import java.util.function.UnaryOperator; @@ -31,6 +32,7 @@ import net.kyori.adventure.text.minimessage.internal.parser.ParsingExceptionImpl; import net.kyori.adventure.text.minimessage.internal.parser.Token; import net.kyori.adventure.text.minimessage.internal.parser.node.TagPart; +import net.kyori.adventure.text.minimessage.internal.util.ListMapHolder; 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.TagResolver; @@ -177,11 +179,17 @@ private Component deserializeWithOptionalTarget(final String message, final TagR } } - private static Token[] tagsToTokens(final List tags) { - final Token[] tokens = new Token[tags.size()]; - for (int i = 0, length = tokens.length; i < length; i++) { - tokens[i] = ((TagPart) tags.get(i)).token(); + private static Token[] tagsToTokens(final ListMapHolder tags) { + final List tokens = new ArrayList<>(tags.map().size() + tags.list().size()); + + for (final Tag.Argument value : tags.map().values()) { + tokens.add(((TagPart) value).token()); + } + + for (final T tag : tags.list()) { + tokens.add(((TagPart) tag).token()); } - return tokens; + + return tokens.toArray(Token[]::new); } } 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 d59587cf5d..6cf6cb08cd 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 @@ -42,6 +42,7 @@ import net.kyori.adventure.text.minimessage.tag.Modifying; import net.kyori.adventure.text.minimessage.tag.Tag; import net.kyori.adventure.text.minimessage.tag.resolver.TagResolver; +import org.jspecify.annotations.Nullable; record MiniMessageParser(TagResolver tagResolver) { @@ -61,7 +62,7 @@ private void escapeTokens(final StringBuilder sb, final String richMessage, fina if (token.type() == TokenType.CLOSE_TAG) { builder.append(TokenParser.CLOSE_TAG); } - final List childTokens = token.childTokens(); + final List childTokens = Objects.requireNonNull(token.childTokens()); for (int i = 0; i < childTokens.size(); i++) { if (i != 0) { builder.append(TokenParser.SEPARATOR); @@ -91,7 +92,7 @@ private void processTokens(final StringBuilder sb, final String richMessage, fin case TEXT -> sb.append(richMessage, token.startIndex(), token.endIndex()); case OPEN_TAG, CLOSE_TAG, OPEN_CLOSE_TAG -> { // extract tag name - if (token.childTokens().isEmpty()) { + if (Objects.requireNonNull(token.childTokens()).isEmpty()) { sb.append(richMessage, token.startIndex(), token.endIndex()); continue; } @@ -107,7 +108,7 @@ private void processTokens(final StringBuilder sb, final String richMessage, fin } } - RootNode parseToTree(final ContextImpl context) { + RootNode parseToTree(final ContextImpl context) { final TagResolver combinedResolver = TagResolver.resolver(this.tagResolver, context.extraTags()); final String processedMessage = context.preProcessor().apply(context.message()); final Consumer debug = context.debugOutput(); @@ -117,7 +118,29 @@ RootNode parseToTree(final ContextImpl context) { debug.accept("\n"); } - final TokenParser.TagProvider transformationFactory; + final TokenParser.TagProvider transformationFactory = this.transformationFactory(context, debug, combinedResolver); + + final Predicate tagNameChecker = name -> { + final String sanitized = TokenParser.TagProvider.sanitizePlaceholderName(name); + return combinedResolver.has(sanitized); + }; + + final String preProcessed = TokenParser.resolvePreProcessTags(processedMessage, transformationFactory); + context.message(preProcessed); + // Then, once MiniMessage placeholders have been inserted, we can do the real parse + final RootNode root = TokenParser.parse(transformationFactory, tagNameChecker, preProcessed, processedMessage, context.strict()); + + if (debug != null) { + debug.accept("Text parsed into element tree:\n"); + debug.accept(root.toString()); + } + + return root; + } + + private TokenParser.TagProvider transformationFactory(final ContextImpl context, final @Nullable Consumer debug, final TagResolver combinedResolver) { + final TokenParser.TagProvider transformationFactory; + if (debug != null) { transformationFactory = (name, args, token) -> { try { @@ -132,30 +155,9 @@ RootNode parseToTree(final ContextImpl context) { final Tag transformation = combinedResolver.resolve(name, new ArgumentQueueImpl<>(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.getClass().getName()); - debug.accept("\n"); - } - - return transformation; + return this.debugPrintTransformation(debug, name, transformation); } catch (final ParsingException e) { - if (token != null && e instanceof final ParsingExceptionImpl impl) { - if (impl.tokens().length == 0) { - impl.tokens(new Token[]{token}); - } - } - debug.accept("Could not match node '"); - debug.accept(name); - debug.accept("' - "); - debug.accept(e.getMessage()); - debug.accept("\n"); + this.handleParsingException(e, token, debug, name); return null; } }; @@ -168,22 +170,36 @@ RootNode parseToTree(final ContextImpl context) { } }; } - final Predicate tagNameChecker = name -> { - final String sanitized = TokenParser.TagProvider.sanitizePlaceholderName(name); - return combinedResolver.has(sanitized); - }; + return transformationFactory; + } - final String preProcessed = TokenParser.resolvePreProcessTags(processedMessage, transformationFactory); - context.message(preProcessed); - // Then, once MiniMessage placeholders have been inserted, we can do the real parse - final RootNode root = TokenParser.parse(transformationFactory, tagNameChecker, preProcessed, processedMessage, context.strict()); + private void handleParsingException(final ParsingException exception, final @Nullable Token token, final Consumer debug, final String name) { + if (token != null && exception instanceof final ParsingExceptionImpl impl) { + if (impl.tokens().length == 0) { + impl.tokens(new Token[]{token}); + } + } + debug.accept("Could not match node '"); + debug.accept(name); + debug.accept("' - "); + debug.accept(exception.getMessage()); + debug.accept("\n"); + } - if (debug != null) { - debug.accept("Text parsed into element tree:\n"); - debug.accept(root.toString()); + private @Nullable Tag debugPrintTransformation(final Consumer debug, final String name, final @Nullable Tag transformation) { + 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.getClass().getName()); + debug.accept("\n"); } - return root; + return transformation; } Component parseFormat(final ContextImpl context) { 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 b2a2432738..27ea678718 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 @@ -131,7 +131,7 @@ private String popTag(final boolean allowMarks) { throw new IllegalStateException("Unbalanced tags, tried to pop below depth"); } final String tag = this.activeTags[this.tagLevel]; - if (!allowMarks && tag == MARK) { + if (!allowMarks && tag.equals(MARK)) { throw new IllegalStateException("Tried to pop past mark, tag stack: " + Arrays.toString(this.activeTags) + " @ " + this.tagLevel); } return tag; @@ -146,7 +146,7 @@ void popToMark() { return; } String tag; - while ((tag = this.popTag(true)) != MARK) { + while (!(tag = this.popTag(true)).equals(MARK)) { this.emitClose(tag); } } @@ -154,7 +154,7 @@ void popToMark() { void popAll() { while (this.tagLevel > 0) { final String tag = this.activeTags[--this.tagLevel]; - if (tag != MARK) { + if (!tag.equals(MARK)) { this.emitClose(tag); } } @@ -198,6 +198,18 @@ public TokenEmitter argument(final String arg) { return this; } + @Override + public TokenEmitter namedArgument(final String name, final 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 TokenEmitter argument(final String arg, final QuotingOverride quotingPreference) { if (!this.tagState.isTag) { @@ -208,12 +220,40 @@ public TokenEmitter argument(final String arg, final QuotingOverride quotingPref return this; } + @Override + public TokenEmitter namedArgument(final String name, final String arg, final 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 TokenEmitter argument(final Component arg) { final String serialized = MiniMessageSerializer.serialize(arg, this.resolver, this.strict); return this.argument(serialized, QuotingOverride.QUOTED); // always quote tokens } + @Override + public TokenEmitter namedArgument(final String name, final Component arg) { + final String serialized = MiniMessageSerializer.serialize(arg, this.resolver, this.strict); + return this.namedArgument(name, serialized, QuotingOverride.QUOTED); // always quote tokens + } + + @Override + public TokenEmitter flag(final 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 Collector text(final String text) { this.completeTag(); @@ -231,13 +271,11 @@ private void escapeTagContent(final String content, final @Nullable QuotingOverr final char active = content.charAt(i); if (active == TokenParser.TAG_END || active == TokenParser.SEPARATOR || active == ' ') { // space is not technically required here, but is preferred mustBeQuoted = true; - if (hasSingleQuote && hasDoubleQuote) break; } else if (active == '\'') { hasSingleQuote = true; break; // we know our quoting style } else if (active == '"') { hasDoubleQuote = true; - if (mustBeQuoted && hasSingleQuote) break; } } 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 cec110cbbc..fb0c7f9177 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 @@ -25,9 +25,13 @@ import java.util.ArrayList; import java.util.Collections; +import java.util.LinkedList; import java.util.List; import java.util.ListIterator; import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.TreeMap; import java.util.function.IntPredicate; import java.util.function.Predicate; import net.kyori.adventure.text.minimessage.ParsingException; @@ -40,9 +44,11 @@ import net.kyori.adventure.text.minimessage.internal.parser.node.TagNode; import net.kyori.adventure.text.minimessage.internal.parser.node.TagPart; import net.kyori.adventure.text.minimessage.internal.parser.node.TextNode; +import net.kyori.adventure.text.minimessage.internal.util.ListMapHolder; 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 org.intellij.lang.annotations.Subst; import org.jetbrains.annotations.ApiStatus; import org.jspecify.annotations.Nullable; @@ -59,6 +65,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 = '\\'; @@ -73,12 +80,13 @@ private TokenParser() { * @param message the minimessage string to parse, after processing for preprocess tags * @param originalMessage the string to parse, before preprocess tags * @param strict whether parsing in strict mode + * @param type of the tag argument * @return the root of the resulting tree * @throws ParsingException if invalid input is provided when in strict mode * @since 4.10.0 */ - public static RootNode parse( - final TagProvider tagProvider, + public static RootNode parse( + final TagProvider tagProvider, final Predicate tagNameChecker, final String message, final String originalMessage, @@ -96,17 +104,18 @@ public static RootNode parse( * * @param message the message * @param provider the tag resolver, to gather preprocess tags + * @param type of the tag argument * @return the resulting string * @since 4.10.0 */ - public static String resolvePreProcessTags(final String message, final TagProvider provider) { + public static String resolvePreProcessTags(final String message, final TagProvider provider) { int passes = 0; String lastResult; String result = message; do { lastResult = result; - final StringResolvingMatchedTokenConsumer stringTokenResolver = new StringResolvingMatchedTokenConsumer(lastResult, provider); + final StringResolvingMatchedTokenConsumer stringTokenResolver = new StringResolvingMatchedTokenConsumer<>(lastResult, provider); parseString(lastResult, false, stringTokenResolver); result = stringTokenResolver.result(); @@ -135,7 +144,7 @@ public static List tokenize(final String message, final boolean lenient) enum FirstPassState { NORMAL, TAG, - STRING; + STRING } /** @@ -285,7 +294,6 @@ public static void parseString(final String message, final boolean lenient, fina /* * Second pass over the tag tokens identifies tag parts */ - @SuppressWarnings("DuplicatedCode") private static void parseSecondPass(final String message, final List tokens) { for (final Token token : tokens) { final TokenType type = token.type(); @@ -297,87 +305,191 @@ private static void parseSecondPass(final String message, final List toke final int startIndex = type == TokenType.CLOSE_TAG ? token.startIndex() + 2 : token.startIndex() + 1; final int endIndex = type == TokenType.OPEN_CLOSE_TAG ? token.endIndex() - 2 : token.endIndex() - 1; - SecondPassState state = SecondPassState.NORMAL; - boolean escaped = false; - char currentStringChar = 0; + final String subString = message.substring(startIndex, endIndex); + final int nameIndex = readIdentifier(subString, 0, false); + final int substringLength = subString.length(); - // Marker is the starting index for the current token - int marker = startIndex; + if (nameIndex >= substringLength) { + // special case where the tag is structured simply like ''. + insert(token, new Token(startIndex, endIndex, TokenType.TAG_VALUE)); + continue; + } - for (int i = startIndex; i < endIndex; i++) { - final int codePoint = message.codePointAt(i); - if (!Character.isBmpCodePoint(i)) { - i++; + int currentIndex = nameIndex; + boolean onlyWhitespace = false; + + char currentChar = subString.charAt(currentIndex); + while (currentChar == ' ') { + currentIndex++; + if (currentIndex >= substringLength) { + onlyWhitespace = true; + break; } + currentChar = subString.charAt(currentIndex); + } - if (!escaped) { - // if we're trying to escape and the next character exists - if (codePoint == ESCAPE && i + 1 < message.length()) { - final int nextCodePoint = message.codePointAt(i + 1); + if (onlyWhitespace) { + // The tag looks something like this ''. + insert(token, new Token(startIndex, endIndex, TokenType.TAG_VALUE)); + continue; + } - escaped = switch (state) { - // allow escaping open tokens - case NORMAL -> nextCodePoint == TAG_START || nextCodePoint == ESCAPE; + // If there are arguments, the tag name should be without spaces + insert(token, new Token(startIndex, startIndex + nameIndex, TokenType.TAG_VALUE)); - // allow escaping closing string chars - case STRING -> currentStringChar == nextCodePoint || nextCodePoint == ESCAPE; - }; + while (true) { + currentChar = subString.charAt(currentIndex); - // only escape if we need to - if (escaped) { - continue; - } - } - } else { - escaped = false; - continue; + if (currentChar == SEPARATOR) { + currentIndex++; + break; } - switch (state) { - case NORMAL: - // Values are split by : unless it's in a URL - if (codePoint == SEPARATOR) { - if (boundsCheck(message, i, 2) && message.charAt(i + 1) == '/' && message.charAt(i + 2) == '/') { - break; - } - if (marker == i) { - // 2 colons side-by-side like <::> or <:text> or would lead to this happening - insert(token, new Token(i, i, TokenType.TAG_VALUE)); - marker++; - } else { - insert(token, new Token(marker, i, TokenType.TAG_VALUE)); - marker = i + 1; - } - } else if (codePoint == '\'' || codePoint == '"') { - state = SecondPassState.STRING; - currentStringChar = (char) codePoint; - } - break; - case STRING: - if (codePoint == currentStringChar) { - state = SecondPassState.NORMAL; - } - break; + final int identifierIndex = readIdentifier(subString, currentIndex, true); + if (identifierIndex == substringLength || (currentChar = subString.charAt(identifierIndex)) == ' ') { + insert(token, new Token(currentIndex + startIndex, identifierIndex + startIndex, TokenType.TAG_VALUE_TOGGLE)); + currentIndex = identifierIndex + 1; + } else if (currentChar == NAME_VALUE_SEPARATOR) { + insert(token, new Token(currentIndex + startIndex, identifierIndex + startIndex, TokenType.TAG_VALUE_NAME)); + currentIndex = identifierIndex + 1; + final int valueIndex = readNamedValue(subString, currentIndex, true); + insert(token, new Token(currentIndex + startIndex, valueIndex + startIndex, TokenType.TAG_VALUE)); + currentIndex = valueIndex + 1; + } + + if (currentIndex >= substringLength) { + break; + } + + currentChar = subString.charAt(currentIndex); + while (currentChar == ' ') { + currentIndex++; + currentChar = subString.charAt(currentIndex); } } - // anything not matched is the final part - if (token.childTokens() == null || token.childTokens().isEmpty()) { - insert(token, new Token(startIndex, endIndex, TokenType.TAG_VALUE)); - } else { - final int end = token.childTokens().getLast().endIndex(); - if (end != endIndex) { - insert(token, new Token(end + 1, endIndex, TokenType.TAG_VALUE)); + if (currentIndex >= substringLength) { + continue; + } + + while (currentIndex < substringLength) { + final int nextIndex = readSequentialValue(subString, currentIndex, true); + insert(token, new Token(currentIndex + startIndex, nextIndex + startIndex, TokenType.TAG_VALUE)); + currentIndex = nextIndex + 1; + + if (currentIndex == substringLength && subString.charAt(currentIndex - 1) == SEPARATOR) { + insert(token, new Token(endIndex, endIndex, TokenType.TAG_VALUE)); + break; } } } } + /** + * Read an identifier. + * + * @param message the inner part of a token + * @param index the index to start reading at + * @param expectNamedSeparator whether to expect a separator between named arguments or a colon for sequential ones + * @return the end index of the identifier + */ + private static int readIdentifier(final String message, final int index, final boolean expectNamedSeparator) { + if (expectNamedSeparator) { + for (int i = index; i < message.length(); i++) { + final char curr = message.charAt(i); + + if (curr == ' ' || curr == NAME_VALUE_SEPARATOR) { + return i; + } + } + } else { + for (int i = index; i < message.length(); i++) { + final char curr = message.charAt(i); + + if (curr == ' ' || curr == SEPARATOR) { + return i; + } + } + } + + return message.length(); + } + + /** + * Read a named value. + * + * @param message the inner part of a token + * @param index the index to start reading at + * @param mayAttemptString whether this pass is allowed to try parse strings + * @return the end index of the value + */ + private static int readNamedValue(final String message, final int index, final boolean mayAttemptString) { + final char firstChar = message.charAt(index); + final boolean attemptString = mayAttemptString && (firstChar == '\'' || firstChar == '"'); + + for (int i = attemptString ? index + 1 : index; i < message.length(); i++) { + final char curr = message.charAt(i); + + if (attemptString) { + if (curr == firstChar && message.charAt(i - 1) != '\\') { + return i + 1; + } + } else if (curr == ' ') { + return i; + } + } + + if (attemptString) { + // No closing ' or " found; trying again, but disabling stringification + return readNamedValue(message, index, false); + } + + return message.length(); + } + + /** + * Read a sequential value. + * + * @param message the inner part of a token + * @param index the index to start reading at + * @param mayAttemptString whether this pass is allowed to try parse strings + * @return the end index of the value + */ + private static int readSequentialValue(final String message, final int index, final boolean mayAttemptString) { + final char firstChar = message.charAt(index); + final boolean attemptString = mayAttemptString && (firstChar == '\'' || firstChar == '"'); + + for (int i = attemptString ? index + 1 : index; i < message.length(); i++) { + final char curr = message.charAt(i); + + if (attemptString) { + if (curr == firstChar && message.charAt(i - 1) != '\\') { + return i + 1; + } + continue; + } + + if (curr == SEPARATOR) { + if (i + 2 < message.length() && message.charAt(i + 1) == '/' && message.charAt(i + 2) == '/') { + continue; + } + return i; + } + } + + if (attemptString) { + // No closing ' or " found; trying again, but disabling stringification + return readSequentialValue(message, index, false); + } + + return message.length(); + } + /* * Build a tree from the OPEN_TAG and CLOSE_TAG tokens */ - private static RootNode buildTree( - final TagProvider tagProvider, + private static RootNode buildTree( + final TagProvider tagProvider, final Predicate tagNameChecker, final List tokens, final String message, @@ -393,8 +505,8 @@ private static RootNode buildTree( case TEXT -> node.addChild(new TextNode(node, token, message)); case OPEN_TAG, OPEN_CLOSE_TAG -> { // Check if this even is a valid tag - final Token tagNamePart = token.childTokens().getFirst(); - final String tagName = message.substring(tagNamePart.startIndex(), tagNamePart.endIndex()); + final @Subst("") Token tagNamePart = Objects.requireNonNull(token.childTokens()).getFirst(); + final @Subst("") String tagName = message.substring(tagNamePart.startIndex(), tagNamePart.endIndex()); if (!TagInternals.sanitizeAndCheckValidTagName(tagName)) { // This wouldn't be a valid tag, just parse it as text instead! node.addChild(new TextNode(node, token, message)); @@ -429,7 +541,7 @@ private static RootNode buildTree( } } case CLOSE_TAG -> { - final List childTokens = token.childTokens(); + final List childTokens = Objects.requireNonNull(token.childTokens()); if (childTokens.isEmpty()) { throw new IllegalStateException("CLOSE_TAG token somehow has no children - " + "the parser should not allow this. Original text: " + message); @@ -582,11 +694,6 @@ private static void insert(final Token token, final Token value) { } } - enum SecondPassState { - NORMAL, - STRING; - } - /** * Removes escaping {@code '\`} characters from a substring where the subsequent character matches a given predicate. * @@ -644,12 +751,13 @@ public static String unescape(final String text, final int startIndex, final int } /** - * Normalizing provider for tag information. + * A provider for tag information. * + * @param tag argument * @since 4.10.0 */ @ApiStatus.Internal - public interface TagProvider { + public interface TagProvider { /** * Look up a tag. * @@ -659,9 +767,9 @@ public interface TagProvider { * @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 + * @since 5.1.0 */ - @Nullable Tag resolve(final String name, final List trimmedArgs, final @Nullable Token token); + @Nullable Tag resolve(final String name, final ListMapHolder trimmedArgs, final @Nullable Token token); /** * Resolve by sanitized name. @@ -671,7 +779,7 @@ public interface TagProvider { * @since 4.10.0 */ default @Nullable Tag resolve(final String name) { - return this.resolve(name, Collections.emptyList(), null); + return this.resolve(name, ListMapHolder.empty(), null); } /** @@ -681,10 +789,32 @@ public interface TagProvider { * @return a tag, if any is available * @since 4.10.0 */ + @SuppressWarnings("unchecked") default @Nullable Tag resolve(final TagNode node) { + final Map map = new TreeMap<>(); + final List list = new LinkedList<>(); + + 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++; + } else if (part.token().type() == TokenType.TAG_VALUE_TOGGLE) { + map.put(part.value(), (T) part); + } else { + list.add((T) part); + } + } + return this.resolve( - sanitizePlaceholderName(node.name()), - node.parts().subList(1, node.parts().size()), + TagProvider.sanitizePlaceholderName(node.name()), + ListMapHolder.of(list, map), node.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 467f422115..b466860041 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 @@ -25,15 +25,18 @@ import java.util.ArrayList; import java.util.List; +import java.util.Map; import java.util.Objects; +import java.util.TreeMap; 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; +import net.kyori.adventure.text.minimessage.internal.util.ListMapHolder; import net.kyori.adventure.text.minimessage.tag.PreProcess; import net.kyori.adventure.text.minimessage.tag.Tag; +import org.intellij.lang.annotations.Subst; import static net.kyori.adventure.text.minimessage.internal.parser.TokenParser.SEPARATOR; import static net.kyori.adventure.text.minimessage.internal.parser.TokenParser.tokenize; @@ -41,11 +44,12 @@ /** * A matched token consumer that produces a string and returns a copy of the string with {@link PreProcess} tags resolved. * + * @param type of the tag argument * @since 4.10.0 */ -public final class StringResolvingMatchedTokenConsumer extends MatchedTokenConsumer { +public final class StringResolvingMatchedTokenConsumer extends MatchedTokenConsumer { private final StringBuilder builder; - private final TagProvider tagProvider; + private final TagProvider tagProvider; /** * Creates a string resolving matched token consumer. @@ -56,7 +60,7 @@ public final class StringResolvingMatchedTokenConsumer extends MatchedTokenConsu */ public StringResolvingMatchedTokenConsumer( final String input, - final TagProvider tagProvider + final TagProvider tagProvider ) { super(input); this.builder = new StringBuilder(input.length()); @@ -76,20 +80,34 @@ public void accept(final int start, final int end, final TokenType tokenType) { final String cleanup = this.input.substring(start + 1, end - 1); final int index = cleanup.indexOf(SEPARATOR); - final String tag = index == -1 ? cleanup : cleanup.substring(0, index); + final @Subst("") String tag = index == -1 ? cleanup : cleanup.substring(0, index); // we might care if it's a valid tag! if (TagInternals.sanitizeAndCheckValidTagName(tag)) { final List tokens = tokenize(match, false); - final List parts = new ArrayList<>(); - final List childs = tokens.isEmpty() ? null : tokens.get(0).childTokens(); - if (childs != null) { - for (int i = 1; i < childs.size(); i++) { - parts.add(new TagPart(match, childs.get(i), this.tagProvider)); + final List sequentialParts = new ArrayList<>(); + final Map namedParts = new TreeMap<>(); + + final List children = tokens.isEmpty() ? null : tokens.getFirst().childTokens(); + if (children != null) { + final List subList = children.subList(1, children.size()); + for (int i = 0, subListSize = subList.size(); i < subListSize; i++) { + final Token token = subList.get(i); + + if (token.type() == TokenType.TAG_VALUE_NAME && i + 1 < subListSize) { + final Token nextToken = subList.get(i + 1); + i += 1; // skip the next token + namedParts.put( + match.substring(token.startIndex(), token.endIndex()), + new TagPart(match, nextToken, this.tagProvider) + ); + } else { + sequentialParts.add(new TagPart(match, token, this.tagProvider)); + } } } // we might care if it's a pre-process! - final Tag replacement = this.tagProvider.resolve(TokenParser.TagProvider.sanitizePlaceholderName(tag), parts, tokens.get(0)); + final Tag replacement = this.tagProvider.resolve(TagProvider.sanitizePlaceholderName(tag), (ListMapHolder) ListMapHolder.of(sequentialParts, namedParts), tokens.getFirst()); if (replacement instanceof PreProcess preProcess) { this.builder.append(Objects.requireNonNull(preProcess.value(), "PreProcess replacements cannot return null")); diff --git a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/internal/parser/node/TagNode.java b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/internal/parser/node/TagNode.java index a864670de8..5cbd848da2 100644 --- a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/internal/parser/node/TagNode.java +++ b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/internal/parser/node/TagNode.java @@ -48,13 +48,14 @@ public final class TagNode extends ElementNode { * @param token the token that created this node * @param sourceMessage the source message * @param tagProvider the tag provider + * @param type of the tag argument * @since 4.10.0 */ - public TagNode( + public TagNode( final ElementNode parent, final Token token, final String sourceMessage, - final TokenParser.TagProvider tagProvider + final TokenParser.TagProvider tagProvider ) { super(parent, token, sourceMessage); this.parts = genParts(token, sourceMessage, tagProvider); @@ -65,10 +66,10 @@ public TagNode( } } - private static List genParts( + private static List genParts( final Token token, final String sourceMessage, - final TokenParser.TagProvider tagProvider + final TokenParser.TagProvider tagProvider ) { final ArrayList parts = new ArrayList<>(); diff --git a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/internal/parser/node/TagPart.java b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/internal/parser/node/TagPart.java index 6d9b98f4da..97f2c7e0dc 100644 --- a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/internal/parser/node/TagPart.java +++ b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/internal/parser/node/TagPart.java @@ -42,12 +42,13 @@ public final class TagPart implements Tag.Argument { * @param sourceMessage the source message * @param token the token that creates this tag part * @param tagResolver the combined tag resolver + * @param type of the tag argument * @since 4.10.0 */ - public TagPart( + public TagPart( final String sourceMessage, final Token token, - final TokenParser.TagProvider tagResolver + final TokenParser.TagProvider tagResolver ) { String v = unquoteAndEscape(sourceMessage, token.startIndex(), token.endIndex()); v = TokenParser.resolvePreProcessTags(v, tagResolver); 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 dc6d6e585f..d407eca251 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 @@ -35,6 +35,7 @@ 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.TagResolver; +import org.intellij.lang.annotations.Subst; import org.jspecify.annotations.Nullable; import static java.util.Objects.requireNonNull; @@ -69,7 +70,7 @@ static TagResolver claimingComponent(final String name, final BiFunction names, final BiFunction handler, final Function componentClaim) { final Set ownNames = new HashSet<>(names); - for (final String name : ownNames) { + for (final @Subst("") String name : ownNames) { TagInternals.assertValidTagName(name); } requireNonNull(handler, "handler"); @@ -100,7 +101,7 @@ static TagResolver claimingStyle(final String name, final BiFunction names, final BiFunction handler, final StyleClaim styleClaim) { final Set ownNames = new HashSet<>(names); - for (final String name : ownNames) { + for (final @Subst("") String name : ownNames) { TagInternals.assertValidTagName(name); } requireNonNull(handler, "handler"); @@ -124,15 +125,15 @@ static TagResolver claimingStyle(final Set names, final BiFunction style = this.claimStyle(); + final StyleClaim style = this.claimStyle(); if (style != null && !consumer.styleClaimed(style.claimKey())) { - final @Nullable Emitable applied = style.apply(serializable.style()); + final Emitable applied = style.apply(serializable.style()); if (applied != null) { consumer.style(style.claimKey(), applied); } } if (!consumer.componentClaimed()) { - final @Nullable Emitable component = this.claimComponent(serializable); + final Emitable component = this.claimComponent(serializable); if (component != null) { consumer.component(component); } diff --git a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/internal/serializer/StyleClaimImpl.java b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/internal/serializer/StyleClaimImpl.java index 745c3ea395..f5ecbca3fa 100644 --- a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/internal/serializer/StyleClaimImpl.java +++ b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/internal/serializer/StyleClaimImpl.java @@ -30,7 +30,7 @@ import net.kyori.adventure.text.format.Style; import org.jspecify.annotations.Nullable; -record StyleClaimImpl(String claimKey, Function lens, Predicate filter, BiConsumer emitable) implements StyleClaim { +record StyleClaimImpl(String claimKey, Function lens, Predicate filter, BiConsumer emitable) implements StyleClaim { @Override public @Nullable Emitable apply(final Style style) { final V element = this.lens.apply(style); 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 670aad1dab..075370a572 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 @@ -78,6 +78,18 @@ default TokenEmitter arguments(final String... args) { */ TokenEmitter argument(final 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 5.1.0 + */ + TokenEmitter namedArgument(final String name, final String arg); + /** * Add a single argument to the current tag. * @@ -90,6 +102,19 @@ default TokenEmitter arguments(final String... args) { */ TokenEmitter argument(final String arg, final 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 5.1.0 + */ + TokenEmitter namedArgument(final String name, final String arg, final QuotingOverride quotingPreference); + /** * Add a single argument to the current tag. * @@ -101,6 +126,30 @@ default TokenEmitter arguments(final String... args) { */ TokenEmitter argument(final 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 5.1.0 + */ + TokenEmitter namedArgument(final String name, final 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 5.1.0 + */ + TokenEmitter flag(final String name, final boolean value); + /** * Emit literal text. * diff --git a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/internal/util/ListMapHolder.java b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/internal/util/ListMapHolder.java new file mode 100644 index 0000000000..5bb52f59fb --- /dev/null +++ b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/internal/util/ListMapHolder.java @@ -0,0 +1,68 @@ +/* + * 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.util; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +/** + * A holder for a string and a map. + * + * @param list list to hold + * @param map map to hold + * @param type of the list + * @param type of the map key + * @param type of the map value + * @since 5.1.0 + */ +public record ListMapHolder(List list, Map map) { + /** + * Create a new empty {@link ListMapHolder}. + * + * @param type of the list + * @param type of the map key + * @param type of the map value + * @return a new empty instance + * @since 5.1.0 + */ + public static ListMapHolder empty() { + return new ListMapHolder<>(Collections.emptyList(), Collections.emptyMap()); + } + + /** + * Create a new {@link ListMapHolder}. + * + * @param list list to hold + * @param map map to hold + * @param type of the list + * @param type of the map key + * @param type of the map value + * @return a new instance + * @since 5.1.0 + */ + public static ListMapHolder of(final List list, final Map map) { + return new ListMapHolder<>(list, map); + } +} diff --git a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/internal/util/package-info.java b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/internal/util/package-info.java new file mode 100644 index 0000000000..ca138790b6 --- /dev/null +++ b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/internal/util/package-info.java @@ -0,0 +1,9 @@ +/** + * A package holding internal utilities for the MiniMessage serializer. + */ +@ApiStatus.Internal +@NullMarked +package net.kyori.adventure.text.minimessage.internal.util; + +import org.jetbrains.annotations.ApiStatus; +import org.jspecify.annotations.NullMarked; diff --git a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/tag/resolver/ArgumentQueue.java b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/tag/resolver/ArgumentQueue.java index e29264a02e..979dbbeeda 100644 --- a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/tag/resolver/ArgumentQueue.java +++ b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/tag/resolver/ArgumentQueue.java @@ -25,6 +25,7 @@ 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.jspecify.annotations.Nullable; @@ -89,4 +90,74 @@ public interface ArgumentQueue { * @since 4.10.0 */ void reset(); + + /** + * 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 5.1.0 + */ + boolean isPresent(String name); + + /** + * Get an argument by its name, returning {@code null} if none was found. + * + * @param name name of the argument + * @return the argument + * @since 5.1.0 + */ + Tag.@Nullable Argument get(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 5.1.0 + */ + TriState flag(String name); + + /** + * Get whether this flag is set, inverted or not. + * + * @param name the name of the flag + * @return whether it is present + * @since 5.1.0 + */ + boolean isFlagPresent(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 5.1.0 + */ + default Tag.Argument orThrow(final 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 5.1.0 + */ + Tag.Argument orThrow(String name, 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 5.1.0 + */ + Tag.Argument orThrow(String name, Supplier errorMessage); } 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 f29b982b59..df8f8acc9c 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 @@ -36,6 +36,7 @@ import net.kyori.adventure.text.minimessage.tag.Tag; import net.kyori.adventure.text.minimessage.tag.TagPattern; import net.kyori.adventure.text.minimessage.tag.standard.StandardTags; +import org.intellij.lang.annotations.Subst; import org.jspecify.annotations.Nullable; import static java.util.Objects.requireNonNull; @@ -120,7 +121,7 @@ static TagResolver resolver(@TagPattern final String name, final BiFunction names, final BiFunction handler) { final Set ownNames = new HashSet<>(names); - for (final String name : ownNames) { + for (final @Subst("") String name : ownNames) { TagInternals.assertValidTagName(name); } requireNonNull(handler, "handler"); @@ -230,7 +231,7 @@ static TagResolver caching(final TagResolver.WithoutArguments resolver) { * @return whether this resolver has a tag with this name * @since 4.10.0 */ - boolean has(final String name); + boolean has(@TagPattern final String name); /** * A resolver that only handles a single tag key. @@ -295,7 +296,7 @@ interface WithoutArguments extends TagResolver { * @since 4.10.0 */ @Override - default boolean has(final String name) { + default boolean has(@TagPattern final String name) { return this.resolve(name) != null; } diff --git a/text-minimessage/src/test/java/net/kyori/adventure/text/minimessage/AbstractTest.java b/text-minimessage/src/test/java/net/kyori/adventure/text/minimessage/AbstractTest.java index b2ce6d264c..252a0c4108 100644 --- a/text-minimessage/src/test/java/net/kyori/adventure/text/minimessage/AbstractTest.java +++ b/text-minimessage/src/test/java/net/kyori/adventure/text/minimessage/AbstractTest.java @@ -24,11 +24,10 @@ package net.kyori.adventure.text.minimessage; import java.util.Arrays; -import java.util.Collections; import java.util.function.UnaryOperator; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.ComponentLike; -import net.kyori.adventure.text.minimessage.tag.Tag; +import net.kyori.adventure.text.minimessage.internal.util.ListMapHolder; import net.kyori.adventure.text.minimessage.tag.resolver.ArgumentQueue; import net.kyori.adventure.text.minimessage.tag.resolver.TagResolver; import net.kyori.adventure.text.serializer.ansi.ANSIComponentSerializer; @@ -75,7 +74,7 @@ public static Context dummyContext(final String originalMessage) { } public static ArgumentQueue emptyArgumentQueue(final Context context) { - return new ArgumentQueueImpl<>(context, Collections.emptyList()); + return new ArgumentQueueImpl<>(context, ListMapHolder.empty()); } public static Component virtualOfChildren(final ComponentLike... children) { 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..1061678b9b --- /dev/null +++ b/text-minimessage/src/test/java/net/kyori/adventure/text/minimessage/MiniMessageNamedArgumentsTest.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.List; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.Style; +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.resolver("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.resolver("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.resolver("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 = "test"; + final Component expected = text("test", RED); + assertParsedEquals(expected, input); + } + + @Test + void testNoArgsAlwaysTreatedAsQueued() { + final String input = ""; + final Component expected = text("pass"); + assertParsedEquals(MiniMessage.miniMessage(), expected, input, + TagResolver.resolver("test", (args, ctx) -> Tag.inserting(text("fail"))), + TagResolver.resolver("test", (args, ctx) -> Tag.inserting(text("pass"))) + ); + } + + @Test + void testArgumentlessNamedTag() { + final String input = ""; + final Component expected = text("Works!"); + assertParsedEquals(MiniMessage.miniMessage(), expected, input, TagResolver.resolver( + "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.resolver( + "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); + } + + @Test + void testCombined() { + final String input = ""; + final Component expected = text("hey!", Style.style(BOLD)); + assertParsedEquals(expected, input, TagResolver.resolver("double", (args, ctx) -> { + if (args.flag("say_wassup").toBooleanOrElse(false)) { + return Tag.inserting(Component.text() + .content(args.get("value").value()) + .style(builder -> { + if (args.popOr("Failed to pop style").isTrue()) { + builder.decoration(BOLD, true); + } else { + builder.decoration(BOLD, false); + } + }) + ); + } + return Tag.inserting(Component.text("Failed!")); + })); + } +} 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 22dd204f71..a9d387479a 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; @@ -330,6 +331,52 @@ void testEscapeIncompleteTags() { this.assertParsedEquals(expected, escaped); } + @Test + void testSequencedArgumentsWithSpace() { + final String input = "Is this part of the named arguments update?"; + final Component expected = Component.text("Is this part of the named arguments update?", RED); + assertParsedEquals(expected, input); + } + + @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 +426,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); } @@ -532,19 +579,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 @@ -555,4 +590,16 @@ void testNonTerminatingQuoteArgument() { this.assertParsedEquals(expected, input); } + + private static final class AlwaysMatchingResolver implements TagResolver { + @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 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 39e710f0cb..685ea14981 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 @@ -443,6 +443,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.resolver("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 'red' at column 0")); + assertTrue(anyMatch(messages, it -> it.startsWith("Successfully matched node 'red' to tag "))); + assertTrue(messages.contains("Attempting to match node 'head' at column 0")); + assertTrue(anyMatch(messages, it -> it.startsWith("Successfully matched node 'head' to tag "))); + assertTrue(messages.contains("Attempting to match node '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; }