Skip to content
11 changes: 9 additions & 2 deletions src/main/java/ch/njol/skript/lang/SkriptParser.java
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import ch.njol.skript.lang.function.FunctionReference;
import ch.njol.skript.lang.parser.DefaultValueData;
import ch.njol.skript.lang.parser.ParseStackOverflowException;
import ch.njol.skript.lang.parser.ExpressionParseCache;
import ch.njol.skript.lang.parser.ParserInstance;
import ch.njol.skript.lang.parser.ParsingStack;
import ch.njol.skript.lang.simplification.Simplifiable;
Expand Down Expand Up @@ -68,8 +69,6 @@
* Used for parsing my custom patterns.<br>
* <br>
* Note: All parse methods print one error at most xor any amount of warnings and lower level log messages. If the given string doesn't match any pattern then nothing is printed.
*
* @author Peter Güttinger
*/
public final class SkriptParser {

Expand Down Expand Up @@ -914,6 +913,8 @@ private boolean checkAcceptedType(Class<?> clazz, Class<?> ... types) {
assert types.length > 0;
assert types.length == 1 || !CollectionUtils.contains(types, Object.class);

ExpressionParseCache failedExprsCache = ParserInstance.get().getExpressionParseCache();
failedExprsCache.push();
try (ParseLogHandler log = SkriptLogger.startParseLogHandler()) {
Expression<? extends T> parsedExpression = parseSingleExpr(true, null, types);
if (parsedExpression != null) {
Expand All @@ -923,6 +924,8 @@ private boolean checkAcceptedType(Class<?> clazz, Class<?> ... types) {
log.clear();

return parseExpressionList(log, types);
} finally {
failedExprsCache.pop();
}
}

Expand All @@ -931,6 +934,8 @@ private boolean checkAcceptedType(Class<?> clazz, Class<?> ... types) {
return null;
}

ExpressionParseCache failedExprsCache = ParserInstance.get().getExpressionParseCache();
failedExprsCache.push();
try (ParseLogHandler log = SkriptLogger.startParseLogHandler()) {
Expression<?> parsedExpression = parseSingleExpr(true, null, exprInfo);
if (parsedExpression != null) {
Expand All @@ -940,6 +945,8 @@ private boolean checkAcceptedType(Class<?> clazz, Class<?> ... types) {
log.clear();

return parseExpressionList(log, exprInfo);
} finally {
failedExprsCache.pop();
}
}

Expand Down
126 changes: 126 additions & 0 deletions src/main/java/ch/njol/skript/lang/parser/ExpressionParseCache.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
package ch.njol.skript.lang.parser;

import ch.njol.skript.classes.ClassInfo;

import java.util.ArrayDeque;
import java.util.Arrays;
import java.util.Deque;
import java.util.HashSet;
import java.util.Set;

/**
* A scoped cache for failed expression parse attempts.
* <p>
* Each {@code parseExpression} call pushes a new scope via {@link #push()}.
* Failures cached within that scope are isolated from parent scopes,
* ensuring that recursive sub-expression parsing does not interfere
* with the parent's cache. The scope is removed via {@link #pop()}
* when the {@code parseExpression} call completes.
*/
public final class ExpressionParseCache {

/**
* A record representing a failed expression parse attempt.
* Contains all inputs that affect whether {@code parseExpression}
* succeeds or fails for a given substring.
*
* @param substring The substring that was attempted to be parsed.
* @param effectiveFlags The effective parse flags (runtime flags masked by the type's flag mask).
* @param classes The ClassInfo types the expression was expected to match.
* @param isPlural Whether each type accepts plural expressions.
* @param isNullable Whether the type is nullable (optional).
* @param time The time state modifier for the type.
*/
public record Failure(
String substring,
int effectiveFlags,
ClassInfo<?>[] classes,
boolean[] isPlural,
boolean isNullable,
int time
) {

@Override
public boolean equals(Object obj) {
if (this == obj)
return true;
if (!(obj instanceof Failure other))
return false;
return effectiveFlags == other.effectiveFlags
&& isNullable == other.isNullable
&& time == other.time
&& substring.equals(other.substring)
&& Arrays.equals(classes, other.classes)
&& Arrays.equals(isPlural, other.isPlural);
}

@Override
public int hashCode() {
int hash = substring.hashCode() * 31 + effectiveFlags;
hash = hash * 31 + Arrays.hashCode(classes);
hash = hash * 31 + Arrays.hashCode(isPlural);
hash = hash * 31 + Boolean.hashCode(isNullable);
hash = hash * 31 + time;
return hash;
}

@Override
public String toString() {
StringBuilder result = new StringBuilder("Failure{\"").append(substring).append("\" as ");
for (int i = 0; i < classes.length; i++) {
if (i > 0)
result.append('/');
result.append(classes[i].getCodeName());
if (isPlural[i])
result.append('s');
}
if (isNullable)
result.append(" (nullable)");
if (time != 0)
result.append(" @").append(time);
result.append(" flags=").append(effectiveFlags).append('}');
return result.toString();
}
}

private final Deque<Set<Failure>> stack = new ArrayDeque<>();

/**
* Pushes a new cache scope. Call at the start of {@code parseExpression}.
*/
public void push() {
stack.push(new HashSet<>());
}

/**
* Pops the current cache scope. Call at the end of {@code parseExpression}.
*/
public void pop() {
stack.poll();
}

/**
* Checks whether the given failure is cached in the current scope.
*/
public boolean contains(Failure failure) {
Set<Failure> current = stack.peek();
return current != null && current.contains(failure);
}

/**
* Caches a failure in the current scope.
*/
public void add(Failure failure) {
Set<Failure> current = stack.peek();
if (current != null)
current.add(failure);
}

/**
* Clears all scopes.
*/
public void clear() {
stack.clear();
}

}
48 changes: 48 additions & 0 deletions src/main/java/ch/njol/skript/lang/parser/LiteralParseCache.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package ch.njol.skript.lang.parser;

import ch.njol.skript.lang.ParseContext;

import java.util.HashSet;
import java.util.Set;

/**
* A cache for literal data strings that failed {@code Classes.parse(data, Object.class, context)}.
* <p>
* Results of {@code Classes.parse} depend only on registered ClassInfo parsers and
* {@link ParseContext}, neither of which change during script loading. This cache
* is therefore safe to retain for an entire script load batch.
*/
public final class LiteralParseCache {

/**
* A record representing a failed literal parse attempt.
*
* @param data The literal data string that failed to parse.
* @param context The parse context in which the parse was attempted.
*/
public record Failure(String data, ParseContext context) {}

private final Set<Failure> failures = new HashSet<>();

/**
* Returns true if the given literal data string is known to be unparsable in the given context.
*/
public boolean contains(Failure failure) {
return failures.contains(failure);
}

/**
* Marks a literal parse attempt as failed.
*/
public void add(Failure failure) {
failures.add(failure);
}

/**
* Clears all cached failures.
*/
public void clear() {
failures.clear();
}

}
25 changes: 25 additions & 0 deletions src/main/java/ch/njol/skript/lang/parser/ParserInstance.java
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ public static ParserInstance get() {
public void setInactive() {
this.isActive = false;
reset();
this.literalParseCache.clear();
setCurrentScript((Script) null);
}

Expand Down Expand Up @@ -92,6 +93,30 @@ public void reset() {
this.node = null;
this.hintManager = new HintManager(this.hintManager.isActive());
dataMap.clear();
this.expressionParseCache.clear();
}

// Expression parse failure cache

private final ExpressionParseCache expressionParseCache = new ExpressionParseCache();

/**
* @return The expression parse failure cache for this parser instance.
*/
public ExpressionParseCache getExpressionParseCache() {
return expressionParseCache;
}

// Literal parse failure cache - can persist a little longer than Expression,
// since ClassInfo parsers don't rely on nearly as much context. Keep an eye on it, though.

private final LiteralParseCache literalParseCache = new LiteralParseCache();

/**
* @return The literal parse failure cache for this parser instance.
*/
public LiteralParseCache getLiteralParseCache() {
return literalParseCache;
}

// Script API
Expand Down
96 changes: 71 additions & 25 deletions src/main/java/ch/njol/skript/patterns/Keyword.java
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,48 @@ abstract class Keyword {
*/
abstract boolean isPresent(String expr);

/**
* Computes the minimum length of input required to match a pattern.
* Walks the linked list of pattern elements and sums mandatory character counts.
* @param first The first element of the pattern.
* @return The minimum number of characters an input must have to possibly match.
*/
public static int computeMinLength(PatternElement first) {
int length = 0;
PatternElement next = first;
while (next != null) {
switch (next) {
case LiteralPatternElement ignored -> {
// Only count non-space characters since spaces are somewhat flexible
// underestimation is safe, over is not.
String literal = next.toString();
for (int i = 0; i < literal.length(); i++) {
if (literal.charAt(i) != ' ')
length++;
}
}
case ChoicePatternElement choicePatternElement -> {
// get min length of options
int min = Integer.MAX_VALUE;
for (PatternElement choice : choicePatternElement.getPatternElements()) {
int choiceLen = computeMinLength(choice);
if (choiceLen < min)
min = choiceLen;
}
if (min != Integer.MAX_VALUE)
length += min;
}
case GroupPatternElement groupPatternElement ->
length += computeMinLength(groupPatternElement.getPatternElement());
default -> {
// OptionalPatternElement, TypePatternElement, RegexPatternElement, ParseTagPatternElement: 0 min length
}
}
next = next.originalNext;
}
return length;
}

/**
* Builds a list of keywords starting from the provided pattern element.
* @param first The pattern to build keywords from.
Expand All @@ -47,24 +89,29 @@ private static Keyword[] buildKeywords(PatternElement first, boolean starting, i
List<Keyword> keywords = new ArrayList<>();
PatternElement next = first;
while (next != null) {
if (next instanceof LiteralPatternElement) { // simple literal strings are keywords
String literal = next.toString().trim();
while (literal.contains(" "))
literal = literal.replace(" ", " ");
if (!literal.isEmpty()) // empty string is not useful
keywords.add(new SimpleKeyword(literal, starting, next.next == null));
} else if (depth <= 1 && next instanceof ChoicePatternElement) { // attempt to build keywords from choices
final boolean finalStarting = starting;
final int finalDepth = depth;
// build the keywords for each choice
Set<Set<Keyword>> choices = ((ChoicePatternElement) next).getPatternElements().stream()
.map(element -> buildKeywords(element, finalStarting, finalDepth))
.map(ImmutableSet::copyOf)
.collect(Collectors.toSet());
if (choices.stream().noneMatch(Collection::isEmpty)) // each choice must have a keyword for this to work
keywords.add(new ChoiceKeyword(choices)); // a keyword where only one choice much
} else if (next instanceof GroupPatternElement) { // add in keywords from the group
Collections.addAll(keywords, buildKeywords(((GroupPatternElement) next).getPatternElement(), starting, depth + 1));
switch (next) {
case LiteralPatternElement ignored -> {
String literal = next.toString().trim();
while (literal.contains(" "))
literal = literal.replace(" ", " ");
if (!literal.isEmpty()) // empty string is not useful
keywords.add(new SimpleKeyword(literal, starting, next.next == null));
}
case ChoicePatternElement choicePatternElement when depth <= 1 -> {
final boolean finalStarting = starting;
final int finalDepth = depth;
// build the keywords for each choice
Set<Set<Keyword>> choices = choicePatternElement.getPatternElements().stream()
.map(element -> buildKeywords(element, finalStarting, finalDepth))
.map(ImmutableSet::copyOf)
.collect(Collectors.toSet());
if (choices.stream().noneMatch(Collection::isEmpty)) // each choice must have a keyword for this to work
keywords.add(new ChoiceKeyword(choices)); // a keyword where only one choice much
}
case GroupPatternElement groupPatternElement -> // add in keywords from the group
Collections.addAll(keywords, buildKeywords(groupPatternElement.getPatternElement(), starting, depth + 1));
default -> {
}
}

// a parse tag does not represent actual content in a pattern, therefore it should not affect starting
Expand Down Expand Up @@ -108,12 +155,11 @@ public int hashCode() {
public boolean equals(Object obj) {
if (this == obj)
return true;
if (!(obj instanceof SimpleKeyword))
if (!(obj instanceof SimpleKeyword simpleKeyword))
return false;
SimpleKeyword other = (SimpleKeyword) obj;
return this.keyword.equals(other.keyword) &&
this.starting == other.starting &&
this.ending == other.ending;
return this.keyword.equals(simpleKeyword.keyword) &&
this.starting == simpleKeyword.starting &&
this.ending == simpleKeyword.ending;
}

@Override
Expand Down Expand Up @@ -152,9 +198,9 @@ public int hashCode() {
public boolean equals(Object obj) {
if (this == obj)
return true;
if (!(obj instanceof ChoiceKeyword))
if (!(obj instanceof ChoiceKeyword choiceKeyword))
return false;
return choices.equals(((ChoiceKeyword) obj).choices);
return choices.equals(choiceKeyword.choices);
}

@Override
Expand Down
Loading