diff --git a/common/src/main/java/me/lucko/luckperms/common/commands/misc/VerboseCommand.java b/common/src/main/java/me/lucko/luckperms/common/commands/misc/VerboseCommand.java index 928e28b23..355b9a958 100644 --- a/common/src/main/java/me/lucko/luckperms/common/commands/misc/VerboseCommand.java +++ b/common/src/main/java/me/lucko/luckperms/common/commands/misc/VerboseCommand.java @@ -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("")) { diff --git a/common/src/main/java/me/lucko/luckperms/common/util/Scripting.java b/common/src/main/java/me/lucko/luckperms/common/util/Scripting.java deleted file mode 100644 index bcc1880e6..000000000 --- a/common/src/main/java/me/lucko/luckperms/common/util/Scripting.java +++ /dev/null @@ -1,47 +0,0 @@ -/* - * This file is part of LuckPerms, licensed under the MIT License. - * - * Copyright (c) lucko (Luck) - * 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; - } - -} diff --git a/common/src/main/java/me/lucko/luckperms/common/verbose/VerboseFilter.java b/common/src/main/java/me/lucko/luckperms/common/verbose/VerboseFilter.java index 21cd73be9..8ee6a62af 100644 --- a/common/src/main/java/me/lucko/luckperms/common/verbose/VerboseFilter.java +++ b/common/src/main/java/me/lucko/luckperms/common/verbose/VerboseFilter.java @@ -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. * - *

The filter is parsed when the instance is initialised - subsequent + *

The expression is compiled when the instance is initialised - subsequent * evaluations should be relatively fast.

*/ 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 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 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 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 expression = expressionBuilder.build(); - testExpression(expression, engine); - return expression; - } - - private static void testExpression(List 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; } } diff --git a/common/src/main/java/me/lucko/luckperms/common/verbose/event/MetaCheckEvent.java b/common/src/main/java/me/lucko/luckperms/common/verbose/event/MetaCheckEvent.java index 81f49add8..c63ed7e7b 100644 --- a/common/src/main/java/me/lucko/luckperms/common/verbose/event/MetaCheckEvent.java +++ b/common/src/main/java/me/lucko/luckperms/common/verbose/event/MetaCheckEvent.java @@ -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 */ diff --git a/common/src/main/java/me/lucko/luckperms/common/verbose/event/PermissionCheckEvent.java b/common/src/main/java/me/lucko/luckperms/common/verbose/event/PermissionCheckEvent.java index 71516d36b..cacd4b611 100644 --- a/common/src/main/java/me/lucko/luckperms/common/verbose/event/PermissionCheckEvent.java +++ b/common/src/main/java/me/lucko/luckperms/common/verbose/event/PermissionCheckEvent.java @@ -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 */ diff --git a/common/src/main/java/me/lucko/luckperms/common/verbose/event/VerboseEvent.java b/common/src/main/java/me/lucko/luckperms/common/verbose/event/VerboseEvent.java index 6fcdad522..e5d7e26b0 100644 --- a/common/src/main/java/me/lucko/luckperms/common/verbose/event/VerboseEvent.java +++ b/common/src/main/java/me/lucko/luckperms/common/verbose/event/VerboseEvent.java @@ -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 diff --git a/common/src/main/java/me/lucko/luckperms/common/verbose/expression/BooleanExpressionCompiler.java b/common/src/main/java/me/lucko/luckperms/common/verbose/expression/BooleanExpressionCompiler.java new file mode 100644 index 000000000..8c43e66e9 --- /dev/null +++ b/common/src/main/java/me/lucko/luckperms/common/verbose/expression/BooleanExpressionCompiler.java @@ -0,0 +1,297 @@ +/* + * This file is part of LuckPerms, licensed under the MIT License. + * + * Copyright (c) lucko (Luck) + * 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 { + 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); + } + */ + +}