Implement a simple compiler (lexer + parser) for verbose filters

Removes the dependency on the Nashorn runtime.
This commit is contained in:
Luck 2019-11-14 22:36:07 +00:00
parent 81cbe02a9c
commit 7a37fcd792
No known key found for this signature in database
GPG Key ID: EFA9B3EC5FD90F8B
7 changed files with 335 additions and 300 deletions

View File

@ -75,9 +75,9 @@ public class VerboseCommand extends SingleCommand {
String filter = filters.isEmpty() ? "" : String.join(" ", filters);
VerboseFilter parsedFilter;
VerboseFilter compiledFilter;
try {
parsedFilter = VerboseFilter.parse(filter);
compiledFilter = new VerboseFilter(filter);
} catch (InvalidFilterException e) {
Message.VERBOSE_INVALID_FILTER.send(sender, filter, e.getCause().getMessage());
return CommandResult.FAILURE;
@ -85,7 +85,7 @@ public class VerboseCommand extends SingleCommand {
boolean notify = !mode.equals("record");
plugin.getVerboseHandler().registerListener(sender, parsedFilter, notify);
plugin.getVerboseHandler().registerListener(sender, compiledFilter, notify);
if (notify) {
if (!filter.equals("")) {

View File

@ -1,47 +0,0 @@
/*
* This file is part of LuckPerms, licensed under the MIT License.
*
* Copyright (c) lucko (Luck) <luck@lucko.me>
* Copyright (c) contributors
*
* 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 me.lucko.luckperms.common.util;
import javax.script.ScriptEngine;
import javax.script.ScriptEngineManager;
/**
* Provides a nashorn script engine (lazily)
*/
public final class Scripting {
private Scripting() {}
private static ScriptEngine engine = null;
// Lazily load
public static synchronized ScriptEngine getScriptEngine() {
if (engine == null) {
engine = new ScriptEngineManager(null).getEngineByName("nashorn");
}
return engine;
}
}

View File

@ -25,115 +25,33 @@
package me.lucko.luckperms.common.verbose;
import com.google.common.collect.ImmutableList;
import me.lucko.luckperms.common.util.Scripting;
import me.lucko.luckperms.common.verbose.event.MetaCheckEvent;
import me.lucko.luckperms.common.verbose.event.PermissionCheckEvent;
import me.lucko.luckperms.common.verbose.event.VerboseEvent;
import java.util.List;
import java.util.StringTokenizer;
import java.util.stream.Collectors;
import javax.script.ScriptEngine;
import javax.script.ScriptException;
import me.lucko.luckperms.common.verbose.expression.BooleanExpressionCompiler;
import me.lucko.luckperms.common.verbose.expression.BooleanExpressionCompiler.AST;
import me.lucko.luckperms.common.verbose.expression.BooleanExpressionCompiler.LexerException;
import me.lucko.luckperms.common.verbose.expression.BooleanExpressionCompiler.ParserException;
/**
* Represents a verbose filter expression.
*
* <p>The filter is parsed when the instance is initialised - subsequent
* <p>The expression is compiled when the instance is initialised - subsequent
* evaluations should be relatively fast.</p>
*/
public final class VerboseFilter {
private final AST ast;
// the characters used in an expression which are part of the expression
// syntax - and not the filter itself.
private static final String DELIMITERS = " |&()!";
// the script engine to use when evaluating the expression
private final ScriptEngine engine;
// the parsed expression
private final List<Token> expression;
/**
* Compiles a {@link VerboseFilter} instance for the given filter string
*
* @param filter the filter
* @return a filter
* @throws InvalidFilterException if the filter is invalid
*/
public static VerboseFilter parse(String filter) throws InvalidFilterException {
ScriptEngine engine = Scripting.getScriptEngine();
if (engine == null) {
throw new RuntimeException("Script engine not present");
}
return new VerboseFilter(engine, filter);
}
private VerboseFilter(ScriptEngine engine, String filter) throws InvalidFilterException {
this.engine = engine;
if (filter.isEmpty()) {
this.expression = ImmutableList.of();
public VerboseFilter(String filterExpression) throws InvalidFilterException {
if (filterExpression.isEmpty()) {
this.ast = AST.ALWAYS_TRUE;
} else {
try {
this.expression = generateExpression(engine, filter);
} catch (Exception e) {
throw new InvalidFilterException("Exception occurred whilst generating an expression for '" + filter + "'", e);
this.ast = BooleanExpressionCompiler.compile(filterExpression);
} catch (LexerException | ParserException e) {
throw new InvalidFilterException("Exception occurred whilst generating an expression for '" + filterExpression + "'", e);
}
}
}
/**
* Parses a filter string into a list of 'tokens' forming the expression.
*
* Each token either represents part of the expressions syntax
* (logical and, logical or, brackets, or space) or a value.
*
* @param engine the script engine to test the expression with
* @param filter the filter string
* @return a parsed list of expressions
* @throws ScriptException if the engine throws an exception whilst evaluating the expression
*/
private static List<Token> generateExpression(ScriptEngine engine, String filter) throws ScriptException {
// tokenize the filter using the filter characters as delimiters.
StringTokenizer tokenizer = new StringTokenizer(filter, DELIMITERS, true);
// use the tokenizer to parse the string to a list of 'tokens'.
ImmutableList.Builder<Token> expressionBuilder = ImmutableList.builder();
while (tokenizer.hasMoreTokens()) {
String token = tokenizer.nextToken();
if (isDelimiter(token)) {
// if the token is a delimiter, just append it to the expression as a constant
expressionBuilder.add(new ConstantToken(token));
} else {
// otherwise consider it to be a value
expressionBuilder.add(new VariableToken(token));
}
}
// build & test the expression
List<Token> expression = expressionBuilder.build();
testExpression(expression, engine);
return expression;
}
private static void testExpression(List<Token> expression, ScriptEngine engine) throws ScriptException {
// build a dummy version of the expression.
// all values are simply replaced by "true"
String dummyExpression = expression.stream().map(Token::forDummyExpression).collect(Collectors.joining());
// do a test run - if the engine returns a result without throwing an exception
// and the result is a boolean, we can consider the expression to be valid
String result = engine.eval(dummyExpression).toString();
if (!result.equals("true") && !result.equals("false")) {
throw new IllegalArgumentException("Expected true/false but got '" + result + "' instead.");
}
}
/**
* Evaluates whether the check data passes the filter
*
@ -141,170 +59,20 @@ public final class VerboseFilter {
* @return if the check data passes the filter
*/
public boolean evaluate(VerboseEvent data) {
if (this.expression.isEmpty()) {
if (isBlank()) {
return true;
}
// build an expression string for the passed check data.
String expressionString = this.expression.stream().map(token -> token.forExpression(data)).collect(Collectors.joining());
// evaluate the expression using the script engine
try {
String result = this.engine.eval(expressionString).toString();
// validate return value
if (!result.equals("true") && !result.equals("false")) {
throw new IllegalArgumentException("Expected true/false but got '" + result + "' instead.");
}
// return the result of the expression
return Boolean.parseBoolean(result);
} catch (Throwable ex) {
// print the error & return false
ex.printStackTrace();
return this.ast.eval(data);
} catch (Exception e) {
e.printStackTrace();
return false;
}
}
public boolean isBlank() {
return this.expression.isEmpty();
}
@Override
public String toString() {
return this.expression.stream().map(Token::toString).collect(Collectors.joining());
}
/**
* Returns true if the string is equal to one of the {@link #DELIMITERS}.
*
* @param string the string
* @return true if delimiter, false otherwise
*/
private static boolean isDelimiter(String string) {
switch (string.charAt(0)) {
case ' ':
case '|':
case '&':
case '(':
case ')':
case '!':
return true;
default:
return false;
}
}
/**
* Represents a part of an expression
*/
private interface Token {
/**
* Returns the value of this token when part of an evaluated expression
*
* @param event the data which an expression is being formed for
* @return the value to be used as part of the evaluated expression
*/
String forExpression(VerboseEvent event);
/**
* Returns a 'dummy' value for this token in order to build a test
* expression.
*
* @return the value to be used as part of the test expression
*/
String forDummyExpression();
}
/**
* Represents a constant part of the expression - tokens will only ever
* consist of the characters defined in the {@link #DELIMITERS} string.
*/
private static final class ConstantToken implements Token {
private final String string;
private ConstantToken(String string) {
// replace single '&' and '|' character with double values
if (string.equals("&")) {
string = "&&";
} else if (string.equals("|")) {
string = "||";
}
this.string = string;
}
@Override
public String forExpression(VerboseEvent event) {
return this.string;
}
@Override
public String forDummyExpression() {
return this.string;
}
@Override
public String toString() {
return this.string;
}
}
/**
* Represents a variable part of the token. When evaluated as an expression,
* this token will be replaced with a boolean 'true' or 'false' - depending
* on if the passed check data "matches" the value of this token.
*
* The check data will be deemed a "match" if:
* - the target of the check is equal to the value of the token
* - the permission/meta key being checked for starts with the value of the token
* - the result of the check is equal to the value of the token
*/
private static final class VariableToken implements Token {
private final String value;
private VariableToken(String value) {
this.value = value;
}
@Override
public String forExpression(VerboseEvent event) {
if (event instanceof PermissionCheckEvent) {
PermissionCheckEvent permissionEvent = (PermissionCheckEvent) event;
return Boolean.toString(
this.value.equals("permission") ||
permissionEvent.getCheckTarget().equalsIgnoreCase(this.value) ||
permissionEvent.getPermission().toLowerCase().startsWith(this.value.toLowerCase()) ||
permissionEvent.getResult().result().name().equalsIgnoreCase(this.value)
);
}
if (event instanceof MetaCheckEvent) {
MetaCheckEvent metaEvent = (MetaCheckEvent) event;
return Boolean.toString(
this.value.equals("meta") ||
metaEvent.getCheckTarget().equalsIgnoreCase(this.value) ||
metaEvent.getKey().toLowerCase().startsWith(this.value.toLowerCase()) ||
metaEvent.getResult().equalsIgnoreCase(this.value)
);
}
throw new IllegalArgumentException("Unknown event type: " + event);
}
@Override
public String forDummyExpression() {
return "true";
}
@Override
public String toString() {
return this.value;
}
return this.ast == AST.ALWAYS_TRUE;
}
}

View File

@ -73,6 +73,14 @@ public class MetaCheckEvent extends VerboseEvent {
.add("origin", this.origin.name().toLowerCase());
}
@Override
public boolean eval(String variable) {
return variable.equals("meta") ||
getCheckTarget().equalsIgnoreCase(variable) ||
getKey().toLowerCase().startsWith(variable.toLowerCase()) ||
getResult().equalsIgnoreCase(variable);
}
/**
* Represents the origin of a meta check
*/

View File

@ -86,6 +86,14 @@ public class PermissionCheckEvent extends VerboseEvent {
object.add("origin", this.origin.name().toLowerCase());
}
@Override
public boolean eval(String variable) {
return variable.equals("permission") ||
getCheckTarget().equalsIgnoreCase(variable) ||
getPermission().toLowerCase().startsWith(variable.toLowerCase()) ||
getResult().result().name().equalsIgnoreCase(variable);
}
/**
* Represents the origin of a permission check
*/

View File

@ -30,6 +30,7 @@ import com.google.gson.JsonObject;
import me.lucko.luckperms.common.util.StackTracePrinter;
import me.lucko.luckperms.common.util.gson.JArray;
import me.lucko.luckperms.common.util.gson.JObject;
import me.lucko.luckperms.common.verbose.expression.BooleanExpressionCompiler.VariableEvaluator;
import net.luckperms.api.context.Context;
import net.luckperms.api.query.QueryMode;
@ -40,7 +41,7 @@ import java.util.Objects;
/**
* Represents a verbose event.
*/
public abstract class VerboseEvent {
public abstract class VerboseEvent implements VariableEvaluator {
/**
* The name of the entity which was checked

View File

@ -0,0 +1,297 @@
/*
* This file is part of LuckPerms, licensed under the MIT License.
*
* Copyright (c) lucko (Luck) <luck@lucko.me>
* Copyright (c) contributors
*
* 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 me.lucko.luckperms.common.verbose.expression;
import com.google.common.collect.AbstractIterator;
import java.io.IOException;
import java.io.StreamTokenizer;
import java.io.StringReader;
/**
* Compiler for boolean expressions with variables.
*/
public class BooleanExpressionCompiler {
/**
* Compiles an {@link AST} for a given boolean expression.
*
* @param expression the expression string
* @return the compiled AST
* @throws LexerException if an error occurs when lexing the expression
* @throws ParserException if an error occurs when parsing the expression
*/
public static AST compile(String expression) throws LexerException, ParserException {
return new Parser(new Lexer(expression)).parse();
}
/**
* Evaluates the value of variables within an expression.
*/
@FunctionalInterface
public interface VariableEvaluator {
/**
* Evaluates the value of a variable.
*
* @param variable the variable
* @return the result
*/
boolean eval(String variable);
}
/**
* AST for a boolean expression.
*/
public interface AST {
AST ALWAYS_TRUE = e -> true;
/**
* Evaluates the AST.
*
* @param variableEvaluator the variable evaluator
* @return the result
*/
boolean eval(VariableEvaluator variableEvaluator);
}
/**
* Represents a lexing error.
*/
public static final class LexerException extends RuntimeException {
LexerException(String message) {
super(message);
}
LexerException(Throwable cause) {
super(cause);
}
}
/**
* Represents a parsing error.
*/
public static final class ParserException extends RuntimeException {
ParserException(String message) {
super(message);
}
}
/**
* Parses a list of {@link Token}s into an {@link AST}.
*/
private static final class Parser {
/*
CFG accepted by this parser:
exp term {OR term}
term factor {AND factor}
factor VARIABLE
factor NOT factor
factor OPEN_BRACKET exp CLOSE_BRACKET
*/
/** The lexer generating the tokens. */
private final Lexer lexer;
/** The current token being parsed */
private Token currentToken;
/** The current root of the AST */
private AST root;
Parser(Lexer lexer) {
this.lexer = lexer;
}
AST parse() {
exp();
return this.root;
}
private void exp() {
term();
while (this.currentToken == ConstantToken.OR) {
Or or = new Or();
or.left = this.root;
term();
or.right = this.root;
this.root = or;
}
}
private void term() {
factor();
while (this.currentToken == ConstantToken.AND) {
And and = new And();
and.left = this.root;
factor();
and.right = this.root;
this.root = and;
}
}
private void factor() {
this.currentToken = this.lexer.next();
if (this.currentToken instanceof VariableToken) {
Variable variable = new Variable();
variable.variable = ((VariableToken) this.currentToken).string;
this.root = variable;
this.currentToken = this.lexer.next();
} else if (this.currentToken == ConstantToken.NOT) {
Not not = new Not();
factor();
not.child = this.root;
this.root = not;
} else if (this.currentToken == ConstantToken.OPEN_BRACKET) {
exp();
if (this.currentToken != ConstantToken.CLOSE_BRACKET) {
throw new ParserException("Brackets are not matched");
}
this.currentToken = this.lexer.next();
} else {
throw new ParserException("Malformed expression");
}
}
}
/* AST implementations */
private static final class And implements AST {
AST left;
AST right;
@Override
public boolean eval(VariableEvaluator variableEvaluator) {
return this.left.eval(variableEvaluator) && this.right.eval(variableEvaluator);
}
}
private static final class Or implements AST {
AST left;
AST right;
@Override
public boolean eval(VariableEvaluator variableEvaluator) {
return this.left.eval(variableEvaluator) || this.right.eval(variableEvaluator);
}
}
private static final class Not implements AST {
AST child;
@Override
public boolean eval(VariableEvaluator variableEvaluator) {
return !this.child.eval(variableEvaluator);
}
}
private static final class Variable implements AST {
String variable;
@Override
public boolean eval(VariableEvaluator variableEvaluator) {
return variableEvaluator.eval(this.variable);
}
}
/**
* Lexes a {@link String} into a list of {@link Token}s.
*/
private static final class Lexer extends AbstractIterator<Token> {
private final StreamTokenizer tokenizer;
private boolean end = false;
Lexer(String expression) {
this.tokenizer = new StreamTokenizer(new StringReader(expression));
this.tokenizer.resetSyntax();
this.tokenizer.wordChars('!', '~'); // all ascii characters
this.tokenizer.whitespaceChars('\u0000', ' ');
"()&|!".chars().forEach(this.tokenizer::ordinaryChar);
}
@Override
protected Token computeNext() {
if (this.end) {
return endOfData();
}
try {
int token = this.tokenizer.nextToken();
switch (token) {
case StreamTokenizer.TT_EOF:
this.end = true;
return ConstantToken.EOF;
case StreamTokenizer.TT_WORD:
return new VariableToken(this.tokenizer.sval);
case '(':
return ConstantToken.OPEN_BRACKET;
case ')':
return ConstantToken.CLOSE_BRACKET;
case '&':
return ConstantToken.AND;
case '|':
return ConstantToken.OR;
case '!':
return ConstantToken.NOT;
default:
throw new LexerException("Unknown token: " + ((char) token) + "(" + token + ")");
}
} catch (IOException e) {
throw new LexerException(e);
}
}
}
private interface Token { }
private enum ConstantToken implements Token {
OPEN_BRACKET, CLOSE_BRACKET, AND, OR, NOT, EOF
}
private static final class VariableToken implements Token {
final String string;
VariableToken(String string) {
this.string = string;
}
}
/*
private static void assertion(String expression, boolean expected) {
if (compile(expression).eval(var -> var.equals("true")) != expected) {
throw new AssertionError(expression + " is not " + expected);
}
}
public static void main(String[] args) {
assertion("false & false | true", true);
assertion("false & (false | true)", false);
assertion("true | false & false", true);
assertion("(true | false) & false", false);
assertion("(true & ((true | false) & !(true & false)))", true);
}
*/
}