Skip to content

Commit be3e363

Browse files
authored
Improve parse times via failure caches and string length pre-check (#8415)
1 parent d74ae27 commit be3e363

8 files changed

Lines changed: 814 additions & 159 deletions

File tree

src/main/java/ch/njol/skript/lang/SkriptParser.java

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import ch.njol.skript.lang.function.FunctionReference;
1616
import ch.njol.skript.lang.parser.DefaultValueData;
1717
import ch.njol.skript.lang.parser.ParseStackOverflowException;
18+
import ch.njol.skript.lang.parser.ExpressionParseCache;
1819
import ch.njol.skript.lang.parser.ParserInstance;
1920
import ch.njol.skript.lang.parser.ParsingStack;
2021
import ch.njol.skript.lang.simplification.Simplifiable;
@@ -68,8 +69,6 @@
6869
* Used for parsing my custom patterns.<br>
6970
* <br>
7071
* 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.
71-
*
72-
* @author Peter Güttinger
7372
*/
7473
public final class SkriptParser {
7574

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

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

925926
return parseExpressionList(log, types);
927+
} finally {
928+
failedExprsCache.pop();
926929
}
927930
}
928931

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

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

942947
return parseExpressionList(log, exprInfo);
948+
} finally {
949+
failedExprsCache.pop();
943950
}
944951
}
945952

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
package ch.njol.skript.lang.parser;
2+
3+
import ch.njol.skript.classes.ClassInfo;
4+
5+
import java.util.ArrayDeque;
6+
import java.util.Arrays;
7+
import java.util.Deque;
8+
import java.util.HashSet;
9+
import java.util.Set;
10+
11+
/**
12+
* A scoped cache for failed expression parse attempts.
13+
* <p>
14+
* Each {@code parseExpression} call pushes a new scope via {@link #push()}.
15+
* Failures cached within that scope are isolated from parent scopes,
16+
* ensuring that recursive sub-expression parsing does not interfere
17+
* with the parent's cache. The scope is removed via {@link #pop()}
18+
* when the {@code parseExpression} call completes.
19+
*/
20+
public final class ExpressionParseCache {
21+
22+
/**
23+
* A record representing a failed expression parse attempt.
24+
* Contains all inputs that affect whether {@code parseExpression}
25+
* succeeds or fails for a given substring.
26+
*
27+
* @param substring The substring that was attempted to be parsed.
28+
* @param effectiveFlags The effective parse flags (runtime flags masked by the type's flag mask).
29+
* @param classes The ClassInfo types the expression was expected to match.
30+
* @param isPlural Whether each type accepts plural expressions.
31+
* @param isNullable Whether the type is nullable (optional).
32+
* @param time The time state modifier for the type.
33+
*/
34+
public record Failure(
35+
String substring,
36+
int effectiveFlags,
37+
ClassInfo<?>[] classes,
38+
boolean[] isPlural,
39+
boolean isNullable,
40+
int time
41+
) {
42+
43+
@Override
44+
public boolean equals(Object obj) {
45+
if (this == obj)
46+
return true;
47+
if (!(obj instanceof Failure other))
48+
return false;
49+
return effectiveFlags == other.effectiveFlags
50+
&& isNullable == other.isNullable
51+
&& time == other.time
52+
&& substring.equals(other.substring)
53+
&& Arrays.equals(classes, other.classes)
54+
&& Arrays.equals(isPlural, other.isPlural);
55+
}
56+
57+
@Override
58+
public int hashCode() {
59+
int hash = substring.hashCode() * 31 + effectiveFlags;
60+
hash = hash * 31 + Arrays.hashCode(classes);
61+
hash = hash * 31 + Arrays.hashCode(isPlural);
62+
hash = hash * 31 + Boolean.hashCode(isNullable);
63+
hash = hash * 31 + time;
64+
return hash;
65+
}
66+
67+
@Override
68+
public String toString() {
69+
StringBuilder result = new StringBuilder("Failure{\"").append(substring).append("\" as ");
70+
for (int i = 0; i < classes.length; i++) {
71+
if (i > 0)
72+
result.append('/');
73+
result.append(classes[i].getCodeName());
74+
if (isPlural[i])
75+
result.append('s');
76+
}
77+
if (isNullable)
78+
result.append(" (nullable)");
79+
if (time != 0)
80+
result.append(" @").append(time);
81+
result.append(" flags=").append(effectiveFlags).append('}');
82+
return result.toString();
83+
}
84+
}
85+
86+
private final Deque<Set<Failure>> stack = new ArrayDeque<>();
87+
88+
/**
89+
* Pushes a new cache scope. Call at the start of {@code parseExpression}.
90+
*/
91+
public void push() {
92+
stack.push(new HashSet<>());
93+
}
94+
95+
/**
96+
* Pops the current cache scope. Call at the end of {@code parseExpression}.
97+
*/
98+
public void pop() {
99+
stack.poll();
100+
}
101+
102+
/**
103+
* Checks whether the given failure is cached in the current scope.
104+
*/
105+
public boolean contains(Failure failure) {
106+
Set<Failure> current = stack.peek();
107+
return current != null && current.contains(failure);
108+
}
109+
110+
/**
111+
* Caches a failure in the current scope.
112+
*/
113+
public void add(Failure failure) {
114+
Set<Failure> current = stack.peek();
115+
if (current != null)
116+
current.add(failure);
117+
}
118+
119+
/**
120+
* Clears all scopes.
121+
*/
122+
public void clear() {
123+
stack.clear();
124+
}
125+
126+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package ch.njol.skript.lang.parser;
2+
3+
import ch.njol.skript.lang.ParseContext;
4+
5+
import java.util.HashSet;
6+
import java.util.Set;
7+
8+
/**
9+
* A cache for literal data strings that failed {@code Classes.parse(data, Object.class, context)}.
10+
* <p>
11+
* Results of {@code Classes.parse} depend only on registered ClassInfo parsers and
12+
* {@link ParseContext}, neither of which change during script loading. This cache
13+
* is therefore safe to retain for an entire script load batch.
14+
*/
15+
public final class LiteralParseCache {
16+
17+
/**
18+
* A record representing a failed literal parse attempt.
19+
*
20+
* @param data The literal data string that failed to parse.
21+
* @param context The parse context in which the parse was attempted.
22+
*/
23+
public record Failure(String data, ParseContext context) {}
24+
25+
private final Set<Failure> failures = new HashSet<>();
26+
27+
/**
28+
* Returns true if the given literal data string is known to be unparsable in the given context.
29+
*/
30+
public boolean contains(Failure failure) {
31+
return failures.contains(failure);
32+
}
33+
34+
/**
35+
* Marks a literal parse attempt as failed.
36+
*/
37+
public void add(Failure failure) {
38+
failures.add(failure);
39+
}
40+
41+
/**
42+
* Clears all cached failures.
43+
*/
44+
public void clear() {
45+
failures.clear();
46+
}
47+
48+
}

src/main/java/ch/njol/skript/lang/parser/ParserInstance.java

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ public static ParserInstance get() {
5151
public void setInactive() {
5252
this.isActive = false;
5353
reset();
54+
this.literalParseCache.clear();
5455
setCurrentScript((Script) null);
5556
}
5657

@@ -92,6 +93,30 @@ public void reset() {
9293
this.node = null;
9394
this.hintManager = new HintManager(this.hintManager.isActive());
9495
dataMap.clear();
96+
this.expressionParseCache.clear();
97+
}
98+
99+
// Expression parse failure cache
100+
101+
private final ExpressionParseCache expressionParseCache = new ExpressionParseCache();
102+
103+
/**
104+
* @return The expression parse failure cache for this parser instance.
105+
*/
106+
public ExpressionParseCache getExpressionParseCache() {
107+
return expressionParseCache;
108+
}
109+
110+
// Literal parse failure cache - can persist a little longer than Expression,
111+
// since ClassInfo parsers don't rely on nearly as much context. Keep an eye on it, though.
112+
113+
private final LiteralParseCache literalParseCache = new LiteralParseCache();
114+
115+
/**
116+
* @return The literal parse failure cache for this parser instance.
117+
*/
118+
public LiteralParseCache getLiteralParseCache() {
119+
return literalParseCache;
95120
}
96121

97122
// Script API

src/main/java/ch/njol/skript/patterns/Keyword.java

Lines changed: 72 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,48 @@ abstract class Keyword {
2626
*/
2727
abstract boolean isPresent(String expr);
2828

29+
/**
30+
* Computes the minimum length of input required to match a pattern.
31+
* Walks the linked list of pattern elements and sums mandatory character counts.
32+
* @param first The first element of the pattern.
33+
* @return The minimum number of characters an input must have to possibly match.
34+
*/
35+
public static int computeMinLength(PatternElement first) {
36+
int length = 0;
37+
PatternElement next = first;
38+
while (next != null) {
39+
switch (next) {
40+
case LiteralPatternElement ignored -> {
41+
// Only count non-space characters since spaces are somewhat flexible
42+
// underestimation is safe, over is not.
43+
String literal = next.toString();
44+
for (int i = 0; i < literal.length(); i++) {
45+
if (literal.charAt(i) != ' ')
46+
length++;
47+
}
48+
}
49+
case ChoicePatternElement choicePatternElement -> {
50+
// get min length of options
51+
int min = Integer.MAX_VALUE;
52+
for (PatternElement choice : choicePatternElement.getPatternElements()) {
53+
int choiceLen = computeMinLength(choice);
54+
if (choiceLen < min)
55+
min = choiceLen;
56+
}
57+
if (min != Integer.MAX_VALUE)
58+
length += min;
59+
}
60+
case GroupPatternElement groupPatternElement ->
61+
length += computeMinLength(groupPatternElement.getPatternElement());
62+
default -> {
63+
// OptionalPatternElement, TypePatternElement, RegexPatternElement, ParseTagPatternElement: 0 min length
64+
}
65+
}
66+
next = next.originalNext;
67+
}
68+
return length;
69+
}
70+
2971
/**
3072
* Builds a list of keywords starting from the provided pattern element.
3173
* @param first The pattern to build keywords from.
@@ -47,24 +89,30 @@ private static Keyword[] buildKeywords(PatternElement first, boolean starting, i
4789
List<Keyword> keywords = new ArrayList<>();
4890
PatternElement next = first;
4991
while (next != null) {
50-
if (next instanceof LiteralPatternElement) { // simple literal strings are keywords
51-
String literal = next.toString().trim();
52-
while (literal.contains(" "))
53-
literal = literal.replace(" ", " ");
54-
if (!literal.isEmpty()) // empty string is not useful
55-
keywords.add(new SimpleKeyword(literal, starting, next.next == null));
56-
} else if (depth <= 1 && next instanceof ChoicePatternElement) { // attempt to build keywords from choices
57-
final boolean finalStarting = starting;
58-
final int finalDepth = depth;
59-
// build the keywords for each choice
60-
Set<Set<Keyword>> choices = ((ChoicePatternElement) next).getPatternElements().stream()
61-
.map(element -> buildKeywords(element, finalStarting, finalDepth))
62-
.map(ImmutableSet::copyOf)
63-
.collect(Collectors.toSet());
64-
if (choices.stream().noneMatch(Collection::isEmpty)) // each choice must have a keyword for this to work
65-
keywords.add(new ChoiceKeyword(choices)); // a keyword where only one choice much
66-
} else if (next instanceof GroupPatternElement) { // add in keywords from the group
67-
Collections.addAll(keywords, buildKeywords(((GroupPatternElement) next).getPatternElement(), starting, depth + 1));
92+
switch (next) {
93+
case LiteralPatternElement ignored -> {
94+
String literal = next.toString().trim();
95+
while (literal.contains(" "))
96+
literal = literal.replace(" ", " ");
97+
if (!literal.isEmpty()) // empty string is not useful
98+
keywords.add(new SimpleKeyword(literal, starting, next.next == null));
99+
}
100+
case ChoicePatternElement choicePatternElement when depth <= 1 -> {
101+
final boolean finalStarting = starting;
102+
final int finalDepth = depth;
103+
// build the keywords for each choice
104+
Set<Set<Keyword>> choices = choicePatternElement.getPatternElements().stream()
105+
.map(element -> buildKeywords(element, finalStarting, finalDepth))
106+
.map(ImmutableSet::copyOf)
107+
.collect(Collectors.toSet());
108+
if (choices.stream().noneMatch(Collection::isEmpty)) // each choice must have a keyword for this to work
109+
keywords.add(new ChoiceKeyword(choices)); // a keyword where only one choice much
110+
}
111+
case GroupPatternElement groupPatternElement -> // add in keywords from the group
112+
Collections.addAll(keywords, buildKeywords(groupPatternElement.getPatternElement(), starting, depth + 1));
113+
default -> {
114+
// OptionalPatternElement, TypePatternElement, RegexPatternElement, ParseTagPatternElement: do not contribute keywords
115+
}
68116
}
69117

70118
// a parse tag does not represent actual content in a pattern, therefore it should not affect starting
@@ -108,12 +156,11 @@ public int hashCode() {
108156
public boolean equals(Object obj) {
109157
if (this == obj)
110158
return true;
111-
if (!(obj instanceof SimpleKeyword))
159+
if (!(obj instanceof SimpleKeyword simpleKeyword))
112160
return false;
113-
SimpleKeyword other = (SimpleKeyword) obj;
114-
return this.keyword.equals(other.keyword) &&
115-
this.starting == other.starting &&
116-
this.ending == other.ending;
161+
return this.keyword.equals(simpleKeyword.keyword) &&
162+
this.starting == simpleKeyword.starting &&
163+
this.ending == simpleKeyword.ending;
117164
}
118165

119166
@Override
@@ -152,9 +199,9 @@ public int hashCode() {
152199
public boolean equals(Object obj) {
153200
if (this == obj)
154201
return true;
155-
if (!(obj instanceof ChoiceKeyword))
202+
if (!(obj instanceof ChoiceKeyword choiceKeyword))
156203
return false;
157-
return choices.equals(((ChoiceKeyword) obj).choices);
204+
return choices.equals(choiceKeyword.choices);
158205
}
159206

160207
@Override

0 commit comments

Comments
 (0)