This is an automated email from the ASF dual-hosted git repository. henrib pushed a commit to branch JEXL-474 in repository https://gitbox.apache.org/repos/asf/commons-jexl.git
commit ac8429885b5b532827ffab0882538abafa6338ea Author: henrib <[email protected]> AuthorDate: Tue Nov 10 15:26:58 2026 +0100 JEXL-474: Declare antish variable names at parse time through JexlFeatures - JexlFeatures.antishTest(Predicate): the parser rewrites references rooted on a global so that the longest declared dotted prefix is a single variable; a script parsed with declared names does not solve antish variables at runtime - with sideEffectGlobal(false) and declared names, assigning a property of a global value is not a global side effect - JexlFeatures.namePatterns(Collection), pragmaPatterns delegates to it - Interpreter: pushBlock/popBlock helpers, AntishName helper for runtime antish resolution - fix: a null local variable root is never solved as an antish variable (read was, assign was not) - fix: for-loop lexical frame leaked when an initializer throws - tests: AntishFeaturesTest, InterpreterCharacterizationTest; JMH InterpreterBenchmark (pom: jmh test deps) Co-Authored-By: Claude Opus 5.5 <[email protected]> --- pom.xml | 12 + src/changes/changes.xml | 1 + .../org/apache/commons/jexl3/JexlFeatures.java | 94 +++++-- .../org/apache/commons/jexl3/internal/Engine.java | 12 + .../apache/commons/jexl3/internal/Interpreter.java | 282 +++++++++++++-------- .../commons/jexl3/parser/FeatureController.java | 18 +- .../apache/commons/jexl3/parser/JexlParser.java | 73 ++++++ .../org/apache/commons/jexl3/parser/Parser.jjt | 6 + .../apache/commons/jexl3/parser/SimpleNode.java | 2 +- .../org/apache/commons/jexl3/AntishCallTest.java | 19 +- .../apache/commons/jexl3/AntishFeaturesTest.java | 265 +++++++++++++++++++ .../jexl3/InterpreterCharacterizationTest.java | 214 ++++++++++++++++ .../jexl3/benchmark/InterpreterBenchmark.java | 142 +++++++++++ 13 files changed, 1012 insertions(+), 128 deletions(-) diff --git a/pom.xml b/pom.xml index ce74ae48..534af2f2 100644 --- a/pom.xml +++ b/pom.xml @@ -131,6 +131,18 @@ <version>3.21.0</version> <scope>test</scope> </dependency> + <dependency> + <groupId>org.openjdk.jmh</groupId> + <artifactId>jmh-core</artifactId> + <version>${commons.jmh.version}</version> + <scope>test</scope> + </dependency> + <dependency> + <groupId>org.openjdk.jmh</groupId> + <artifactId>jmh-generator-annprocess</artifactId> + <version>${commons.jmh.version}</version> + <scope>test</scope> + </dependency> </dependencies> <build> <defaultGoal>clean verify apache-rat:check spotbugs:check japicmp:cmp checkstyle:check javadoc:javadoc</defaultGoal> diff --git a/src/changes/changes.xml b/src/changes/changes.xml index 5e8b71c1..33beeb12 100644 --- a/src/changes/changes.xml +++ b/src/changes/changes.xml @@ -31,6 +31,7 @@ <!-- FIX --> <action dev="henrib" due-to="Claude" type="fix" issue="JEXL-475">A jexl.options pragma modifies the engine options when evaluated with a plain context.</action> <!-- ADD --> + <action dev="henrib" due-to="Claude" type="add" issue="JEXL-474">Declare antish variable names at parse time through JexlFeatures.antishTest(Predicate), disabling runtime antish resolution; add JexlFeatures.namePatterns(Collection).</action> <!-- UPDATE --> <action type="update" dev="ggregory" due-to="Gary Gregory">Bump org.apache.commons:commons-parent from 105 to 107.</action> </release> diff --git a/src/main/java/org/apache/commons/jexl3/JexlFeatures.java b/src/main/java/org/apache/commons/jexl3/JexlFeatures.java index 55b5f401..66e2f12a 100644 --- a/src/main/java/org/apache/commons/jexl3/JexlFeatures.java +++ b/src/main/java/org/apache/commons/jexl3/JexlFeatures.java @@ -332,6 +332,9 @@ public final class JexlFeatures { /** The allowed pragma keys. */ private Predicate<String> pragmas = TEST_STR_TRUE; + /** The declared antish variable names. */ + private Predicate<String> antishNames = TEST_STR_FALSE; + /** * Creates default instance, equivalent to the result of calling the preferred alternative * {@link JexlFeatures#createDefault()} @@ -348,6 +351,7 @@ public final class JexlFeatures { public JexlFeatures(final JexlFeatures features) { this(features.flags, features.reservedNames, features.nameSpaces); this.pragmas = features.pragmas; + this.antishNames = features.antishNames; } /** @@ -365,6 +369,41 @@ public final class JexlFeatures { this.nameSpaces = n == null? TEST_STR_FALSE : n; } + /** + * Gets the declared antish variable names test. + * + * @return The declared antish variable names test, never null. + * @since 3.7.2 + */ + public Predicate<String> antishTest() { + return antishNames; + } + + /** + * Sets a test to declare antish variable names, resolved at parse time. + * <p> + * An antish variable is a global variable whose name contains dots, like <code>x.y</code>. + * When set, the parser rewrites a reference rooted on a global variable, like <code>x.y.z</code>, + * so that its longest dotted prefix satisfying the predicate, like <code>x.y</code>, is a single variable; + * the remaining segments, like <code>.z</code>, are property accesses on that variable value. + * A local variable root, a safe navigation segment (<code>?.</code>) or an expression segment + * (<code>`...`</code>) stops the rewrite. + * </p> + * <p> + * Declaring antish names means they are all known beforehand: a script parsed with such features + * does not solve antish variables at runtime, regardless of the {@link JexlOptions#setAntish(boolean)} option. + * </p> + * <p>{@link #namePatterns(Collection)} creates such a test from name patterns.</p> + * + * @param names The antish variable name predicate; must be stateless or thread-safe; null declares none + * @return this features instance + * @since 3.7.2 + */ + public JexlFeatures antishTest(final Predicate<String> names) { + antishNames = names == null ? TEST_STR_FALSE : names; + return this; + } + /** * Sets whether annotation constructs are enabled. * <p> @@ -484,6 +523,9 @@ public final class JexlFeatures { if (!this.pragmas.equals(other.pragmas)) { return false; } + if (!this.antishNames.equals(other.antishNames)) { + return false; + } if (!Objects.equals(this.reservedNames, other.reservedNames)) { return false; } @@ -540,6 +582,7 @@ public final class JexlFeatures { hash = 53 * hash + Long.hashCode(this.flags); hash = 53 * hash + (this.reservedNames != null ? this.reservedNames.hashCode() : 0); hash = 53 * hash + this.pragmas.hashCode(); + hash = 53 * hash + this.antishNames.hashCode(); return hash; } @@ -830,19 +873,34 @@ public final class JexlFeatures { /** * Creates a pragma key test from a collection of patterns, meant for {@link #pragmaTest(Predicate)}. - * <p> - * A pattern ending with <code>*</code> matches any key starting with what precedes it, any other - * pattern matches the key exactly. For instance, <code>dlm.*</code> matches <code>dlm.foo</code> and - * <code>dlm.foo.bar</code>; <code>*</code> matches any key. Patterns are trimmed, null or empty - * patterns are ignored. - * </p> - * <p>Tests created from the same patterns are equal, so are the features using them.</p> + * <p>This is equivalent to {@link #namePatterns(Collection)}.</p> * * @param patterns The allowed pragma key patterns; null or empty allows no pragma key * @return The pragma key test * @since 3.7.1 */ public static Predicate<String> pragmaPatterns(final Collection<String> patterns) { + return namePatterns(patterns); + } + + /** + * Creates a dotted name test from a collection of patterns. + * <p> + * Meant for features that test names, like {@link #pragmaTest(Predicate)} or {@link #antishTest(Predicate)}. + * </p> + * <p> + * A pattern ending with <code>*</code> matches any name starting with what precedes it, any other + * pattern matches the name exactly. For instance, <code>dlm.*</code> matches <code>dlm.foo</code> and + * <code>dlm.foo.bar</code>; <code>*</code> matches any name. Patterns are trimmed, null or empty + * patterns are ignored. + * </p> + * <p>Tests created from the same patterns are equal, so are the features using them.</p> + * + * @param patterns The name patterns; null or empty matches no name + * @return The name test + * @since 3.7.2 + */ + public static Predicate<String> namePatterns(final Collection<String> patterns) { final Set<String> exact = new TreeSet<>(); final Set<String> prefixes = new TreeSet<>(); if (patterns != null) { @@ -858,19 +916,19 @@ public final class JexlFeatures { } } } - return exact.isEmpty() && prefixes.isEmpty() ? TEST_STR_FALSE : new PragmaPatterns(exact, prefixes); + return exact.isEmpty() && prefixes.isEmpty() ? TEST_STR_FALSE : new NamePatterns(exact, prefixes); } /** - * A pragma key test made of exact keys and key prefixes. + * A name test made of exact names and name prefixes. */ - private static final class PragmaPatterns implements Predicate<String> { - /** The exactly matched keys. */ + private static final class NamePatterns implements Predicate<String> { + /** The exactly matched names. */ private final Set<String> exact; - /** The matched key prefixes. */ + /** The matched name prefixes. */ private final Set<String> prefixes; - PragmaPatterns(final Set<String> exact, final Set<String> prefixes) { + NamePatterns(final Set<String> exact, final Set<String> prefixes) { this.exact = exact; this.prefixes = prefixes; } @@ -896,10 +954,10 @@ public final class JexlFeatures { if (this == obj) { return true; } - if (!(obj instanceof PragmaPatterns)) { + if (!(obj instanceof NamePatterns)) { return false; } - final PragmaPatterns other = (PragmaPatterns) obj; + final NamePatterns other = (NamePatterns) obj; return exact.equals(other.exact) && prefixes.equals(other.prefixes); } @@ -1033,6 +1091,12 @@ public final class JexlFeatures { * When disabled, parsing a script/expression using syntactical constructs modifying variables * <em>including all potentially ant-ish variables</em> will throw a parsing exception. * </p> + * <p> + * When antish variable names are declared ({@link #antishTest(Predicate)}), they are all solved at parse time; + * only assigning a global variable, like <code>x = 1</code> or <code>x.y = 1</code> when <code>x.y</code> + * is declared, is then a global side effect. Assigning a property of a global variable value, like + * <code>x.y.z = 1</code>, is not. + * </p> * * @param flag true to enable, false to disable * @return this features instance diff --git a/src/main/java/org/apache/commons/jexl3/internal/Engine.java b/src/main/java/org/apache/commons/jexl3/internal/Engine.java index 06fbc8cc..5a8aeafb 100644 --- a/src/main/java/org/apache/commons/jexl3/internal/Engine.java +++ b/src/main/java/org/apache/commons/jexl3/internal/Engine.java @@ -408,6 +408,10 @@ public class Engine extends JexlEngine implements JexlUberspect.ConstantResolver if (!features.supportsNamespaceInstantiation()) { options.setNamespaceInstantiation(false); } + // declared antish names are all solved at parse time: no run-time solving + if (features.antishTest() != JexlFeatures.TEST_STR_FALSE) { + options.setAntish(false); + } this.charset = conf.charset(); // caching: final IntFunction<JexlCache<?, ?>> factory = conf.cacheFactory(); @@ -545,6 +549,14 @@ public class Engine extends JexlEngine implements JexlUberspect.ConstantResolver if (opts != options && !scriptFeatures.supportsNamespaceInstantiation()) { opts.setNamespaceInstantiation(false); } + // declared antish names are all solved at parse time: no run-time solving + final JexlFeatures features = script != null && script.getFeatures() != null ? script.getFeatures() : scriptFeatures; + if (features.antishTest() != JexlFeatures.TEST_STR_FALSE && opts.isAntish()) { + if (opts == options) { + opts = opts.copy(); + } + opts.setAntish(false); + } return opts; } diff --git a/src/main/java/org/apache/commons/jexl3/internal/Interpreter.java b/src/main/java/org/apache/commons/jexl3/internal/Interpreter.java index 227c337e..b3723eff 100644 --- a/src/main/java/org/apache/commons/jexl3/internal/Interpreter.java +++ b/src/main/java/org/apache/commons/jexl3/internal/Interpreter.java @@ -37,6 +37,7 @@ import org.apache.commons.jexl3.JxltEngine; import org.apache.commons.jexl3.introspection.JexlMethod; import org.apache.commons.jexl3.introspection.JexlPropertyGet; import org.apache.commons.jexl3.parser.*; +import org.apache.commons.logging.Log; /** * An interpreter of JEXL syntax. @@ -100,6 +101,87 @@ public class Interpreter extends InterpreterBase { } } + /** + * The dotted name of an antish variable, like {@code x.y.z}, built from the children of a reference. + * <p>The first child of the reference is the root identifier; the following children are appended + * as they are traversed, as long as they are plain identifier accesses.</p> + */ + private static final class AntishName { + + /** + * Checks whether an identifier access can be part of an antish variable name. + * + * @param access The identifier access + * @return true if neither safe navigation nor an expression, false otherwise + */ + static boolean isPlain(final ASTIdentifierAccess access) { + return !access.isSafe() && !access.isExpression(); + } + + /** The name. */ + private final StringBuilder name; + + /** The index of the next reference child to append. */ + private int next = 1; + + /** + * Creates an antish name. + * + * @param root The root identifier name + */ + AntishName(final String root) { + name = new StringBuilder(root); + } + + /** + * Appends a dot and a name segment. + * + * @param segment The name segment + */ + void append(final String segment) { + name.append('.').append(segment); + } + + /** + * Appends the names of the reference children not yet appended, up to and including an index. + * + * @param reference The reference node + * @param index The index of the last child to append + * @return null if all children were appended, the first child that can not be part of an antish name otherwise + */ + JexlNode appendUpTo(final JexlNode reference, final int index) { + for (; next <= index; ++next) { + final JexlNode child = reference.jjtGetChild(next); + if (!(child instanceof ASTIdentifierAccess) || !isPlain((ASTIdentifierAccess) child)) { + return child; + } + append(((ASTIdentifierAccess) child).getName()); + } + return null; + } + + /** + * @return The name length + */ + int length() { + return name.length(); + } + + /** + * Truncates the name. + * + * @param length The new name length + */ + void setLength(final int length) { + name.setLength(length); + } + + @Override + public String toString() { + return name.toString(); + } + } + /** * The thread local interpreter. */ @@ -330,28 +412,25 @@ public class Interpreter extends InterpreterBase { final int symbol = catchVariable.getSymbol(); final boolean lexical = catchVariable.isLexical() || options.isLexical(); if (lexical) { - // create lexical frame - final LexicalFrame locals = new LexicalFrame(frame, block); + pushBlock(); + } + try { // it may be a local previously declared - final boolean trySymbol = symbol >= 0 && catchVariable instanceof ASTVar; - if (trySymbol && !defineVariable((ASTVar) catchVariable, locals)) { + final boolean trySymbol = lexical && symbol >= 0 && catchVariable instanceof ASTVar; + if (trySymbol && !defineVariable((ASTVar) catchVariable, block)) { return redefinedVariable(catchVar.jjtGetParent(), catchVariable.getName()); } - block = locals; - } - if (symbol < 0) { - setContextVariable(catchVar.jjtGetParent(), catchVariable.getName(), caught); - } else { - final Throwable cause = caught.getCause(); - frame.set(symbol, cause == null? caught : cause); - } - try { + if (symbol < 0) { + setContextVariable(catchVar.jjtGetParent(), catchVariable.getName(), caught); + } else { + final Throwable cause = caught.getCause(); + frame.set(symbol, cause == null? caught : cause); + } // evaluate body return catchBody.jjtAccept(this, data); } finally { - // restore lexical frame if (lexical) { - block = block.pop(); + popBlock(); } } } @@ -503,8 +582,7 @@ public class Interpreter extends InterpreterBase { } // 1: follow children till penultimate, resolve dot/array JexlNode objectNode = null; - StringBuilder ant = null; - int v = 1; + AntishName ant = null; // start at 1 if symbol main: for (int c = symbol >= 0 ? 1 : 0; c < last; ++c) { objectNode = left.jjtGetChild(c); @@ -524,21 +602,12 @@ public class Interpreter extends InterpreterBase { antish = false; break main; } - ant = new StringBuilder(firstId.getName()); + ant = new AntishName(firstId.getName()); } - // catch up to current child - for (; v <= c; ++v) { - final JexlNode child = left.jjtGetChild(v); - final ASTIdentifierAccess aid = child instanceof ASTIdentifierAccess - ? (ASTIdentifierAccess) child - : null; - // remain antish only if unsafe navigation - if (aid == null || aid.isSafe() || aid.isExpression()) { - antish = false; - break main; - } - ant.append('.'); - ant.append(aid.getName()); + // catch up to current child, remain antish only if unsafe navigation + if (ant.appendUpTo(left, c) != null) { + antish = false; + break main; } // solve antish object = context.get(ant.toString()); @@ -554,8 +623,7 @@ public class Interpreter extends InterpreterBase { final Object property; if (propertyId != null) { // deal with creating/assigning antish variable - if (antish && ant != null && object == null && !propertyId.isSafe() && !propertyId.isExpression()) { - ant.append('.'); + if (antish && ant != null && object == null && AntishName.isPlain(propertyId)) { ant.append(propertyId.getName()); final String name = ant.toString(); if (assignop == null) { @@ -609,18 +677,14 @@ public class Interpreter extends InterpreterBase { final ASTIdentifier loopVariable = (ASTIdentifier) loopReference.jjtGetChild(0); final int symbol = loopVariable.getSymbol(); final boolean lexical = loopVariable.isLexical() || options.isLexical(); - final LexicalFrame locals = lexical? new LexicalFrame(frame, block) : null; + final LexicalFrame locals = lexical ? pushBlock() : null; final boolean loopSymbol = symbol >= 0 && loopVariable instanceof ASTVar; - if (lexical) { - // create lexical frame + Object forEach = null; + try { // it may be a local previously declared - if (loopSymbol && !defineVariable((ASTVar) loopVariable, locals)) { + if (lexical && loopSymbol && !defineVariable((ASTVar) loopVariable, locals)) { return redefinedVariable(node, loopVariable.getName()); } - block = locals; - } - Object forEach = null; - try { /* second objectNode is the variable to iterate */ final Object iterableValue = node.jjtGetChild(1).jjtAccept(this, data); // make sure there is a value to iterate upon @@ -671,9 +735,8 @@ public class Interpreter extends InterpreterBase { } finally { // closeable iterator handling closeIfSupported(forEach); - // restore lexical frame if (lexical) { - block = block.pop(); + popBlock(); } } return result; @@ -681,44 +744,34 @@ public class Interpreter extends InterpreterBase { private Object forLoop(final ASTForeachStatement node, final Object data) { Object result = null; - int nc; + int nc = 0; final int form = node.getLoopForm(); - final LexicalFrame locals; /* first child node might be the loop variable */ - if ((form & 1) != 0) { - nc = 1; - final JexlNode init = node.jjtGetChild(0); - ASTVar loopVariable = null; - if (init instanceof ASTAssignment) { - final JexlNode child = init.jjtGetChild(0); - if (child instanceof ASTVar) { - loopVariable = (ASTVar) child; - } - } else if (init instanceof ASTVar){ - loopVariable = (ASTVar) init; - } - if (loopVariable != null) { - final boolean lexical = loopVariable.isLexical() || options.isLexical(); - locals = lexical ? new LexicalFrame(frame, block) : null; - if (locals != null) { - block = locals; - } - } else { - locals = null; + final JexlNode init = (form & 1) != 0 ? node.jjtGetChild(nc++) : null; + ASTVar loopVariable = null; + if (init instanceof ASTAssignment) { + final JexlNode child = init.jjtGetChild(0); + if (child instanceof ASTVar) { + loopVariable = (ASTVar) child; } - // initialize after eventual creation of local lexical frame - init.jjtAccept(this, data); - // other inits - for (JexlNode moreAssignment = node.jjtGetChild(nc); - moreAssignment instanceof ASTAssignment; - moreAssignment = node.jjtGetChild(++nc)) { - moreAssignment.jjtAccept(this, data); - } - } else { - locals = null; - nc = 0; + } else if (init instanceof ASTVar){ + loopVariable = (ASTVar) init; + } + final boolean lexical = loopVariable != null && (loopVariable.isLexical() || options.isLexical()); + if (lexical) { + pushBlock(); } try { + if (init != null) { + // initialize after eventual creation of local lexical frame + init.jjtAccept(this, data); + // other inits + for (JexlNode moreAssignment = node.jjtGetChild(nc); + moreAssignment instanceof ASTAssignment; + moreAssignment = node.jjtGetChild(++nc)) { + moreAssignment.jjtAccept(this, data); + } + } // the loop condition final JexlNode predicate = (form & 2) != 0? node.jjtGetChild(nc++) : null; // the loop step @@ -745,9 +798,8 @@ public class Interpreter extends InterpreterBase { } } } finally { - // restore lexical frame - if (locals != null) { - block = block.pop(); + if (lexical) { + popBlock(); } } return result; @@ -919,6 +971,23 @@ public class Interpreter extends InterpreterBase { : stmt.call(); } + /** + * Pops the current lexical frame, restoring the enclosing one. + */ + private void popBlock() { + block = block.pop(); + } + + /** + * Pushes a new lexical frame, enclosed by the current one, as the current block. + * + * @return the new current block + */ + private LexicalFrame pushBlock() { + block = new LexicalFrame(frame, block); + return block; + } + /** * Swaps the current thread local interpreter. * @@ -967,14 +1036,14 @@ public class Interpreter extends InterpreterBase { if (numChildren == 0) { return null; } - block = new LexicalFrame(frame, block).defineArgs(); + pushBlock().defineArgs(); try { final JexlNode body = script instanceof ASTJexlLambda ? script.jjtGetChild(numChildren - 1) : script; return interpret(body); } finally { - block = block.pop(); + popBlock(); } } @@ -1154,11 +1223,11 @@ public class Interpreter extends InterpreterBase { if (cnt <= 0) { return visitBlock(node, data); } + pushBlock(); try { - block = new LexicalFrame(frame, block); return visitBlock(node, data); } finally { - block = block.pop(); + popBlock(); } } @@ -1562,11 +1631,11 @@ public class Interpreter extends InterpreterBase { } return closure; } - block = new LexicalFrame(frame, block).defineArgs(); + pushBlock().defineArgs(); try { return runScript(script, data); } finally { - block = block.pop(); + popBlock(); } } @@ -1833,9 +1902,8 @@ public class Interpreter extends InterpreterBase { Object object = null; JexlNode objectNode = null; JexlNode ptyNode = null; - StringBuilder ant = null; + AntishName ant = null; boolean antish = !(parent instanceof ASTReference) && options.isAntish(); - int v = 1; main: for (int c = 0; c < numChildren; c++) { objectNode = node.jjtGetChild(c); @@ -1847,7 +1915,6 @@ public class Interpreter extends InterpreterBase { final JexlNode child = objectNode.jjtGetChild(0); if (child instanceof ASTIdentifierAccess) { final int alen = ant.length(); - ant.append('.'); ant.append(((ASTIdentifierAccess) child).getName()); object = context.get(ant.toString()); if (object != null) { @@ -1855,7 +1922,7 @@ public class Interpreter extends InterpreterBase { continue; } // remove method name from antish - ant.delete(alen, ant.length()); + ant.setLength(alen); ptyNode = objectNode; } } @@ -1885,24 +1952,23 @@ public class Interpreter extends InterpreterBase { break main; } final ASTIdentifier afirst = (ASTIdentifier) first; - ant = new StringBuilder(afirst.getName()); + ant = new AntishName(afirst.getName()); + if (afirst.getSymbol() >= 0) { + // top level is a symbol (a null local variable), cannot be an antish var; same as in executeAssign + ptyNode = numChildren > 1 ? node.jjtGetChild(1) : objectNode; + break main; + } continue; // skip the first node case since it was trialed in jjtAccept above and returned null } // catch up to current node - for (; v <= c; ++v) { - final JexlNode child = node.jjtGetChild(v); - if (!(child instanceof ASTIdentifierAccess)) { + final JexlNode stop = ant.appendUpTo(node, c); + if (stop != null) { + if (!(stop instanceof ASTIdentifierAccess)) { // not an identifier, not antish ptyNode = objectNode; - break main; } - final ASTIdentifierAccess achild = (ASTIdentifierAccess) child; - if (achild.isSafe() || achild.isExpression()) { - break main; - } - ant.append('.'); - ant.append(achild.getName()); + break main; } // solve antish object = context.get(ant.toString()); @@ -2191,13 +2257,10 @@ public class Interpreter extends InterpreterBase { final int bodyChild = node.jjtGetNumChildren() - 1; final JexlNode tryBody = node.jjtGetChild(bodyChild); final Queue<Object> tryResult = new ArrayDeque<>(bodyChild); - final LexicalFrame locals; // sequence of var declarations with/without assignment - if (node.getSymbolCount() > 0) { - locals = new LexicalFrame(frame, block); - block = locals; - } else { - locals = null; + final boolean lexical = node.getSymbolCount() > 0; + if (lexical) { + pushBlock(); } try { for(int c = 0; c < bodyChild; ++c) { @@ -2211,9 +2274,8 @@ public class Interpreter extends InterpreterBase { return tryBody.jjtAccept(this, data); } finally { closeIfSupported(tryResult); - // restore lexical frame - if (locals != null) { - block = block.pop(); + if (lexical) { + popBlock(); } } } @@ -2408,7 +2470,7 @@ public class Interpreter extends InterpreterBase { try { return node.jjtAccept(this, data); } finally { - block = block.pop(); + popBlock(); } } } diff --git a/src/main/java/org/apache/commons/jexl3/parser/FeatureController.java b/src/main/java/org/apache/commons/jexl3/parser/FeatureController.java index d19a2e17..de398cb8 100644 --- a/src/main/java/org/apache/commons/jexl3/parser/FeatureController.java +++ b/src/main/java/org/apache/commons/jexl3/parser/FeatureController.java @@ -48,9 +48,25 @@ public class FeatureController extends ScriptVisitor { node.jjtAccept(this, null); } + /** + * Checks whether assigning a left value is a global side effect. + * <p>When antish variables are declared, they are all solved at parse time; only assigning + * a global variable is a global side effect, assigning a property of its value is not. + * Otherwise, assigning any property of a global may create an antish variable.</p> + * + * @param lv the left value + * @return true if the assignment modifies a global variable + */ + private boolean isGlobalSideEffect(final JexlNode lv) { + if (features.antishTest() != JexlFeatures.TEST_STR_FALSE) { + return lv instanceof ASTIdentifier && lv.isGlobalVar(); + } + return lv.isGlobalVar(); + } + private Object controlSideEffect(final JexlNode node, final Object data) { final JexlNode lv = node.jjtGetChild(0); - if (!features.supportsSideEffectGlobal() && lv.isGlobalVar()) { + if (!features.supportsSideEffectGlobal() && isGlobalSideEffect(lv)) { throwFeatureException(JexlFeatures.SIDE_EFFECT_GLOBAL, lv); } if (features.supportsConstCapture() && lv instanceof ASTIdentifier && ((ASTIdentifier) lv).isCaptured()) { diff --git a/src/main/java/org/apache/commons/jexl3/parser/JexlParser.java b/src/main/java/org/apache/commons/jexl3/parser/JexlParser.java index c4511574..81e65c94 100644 --- a/src/main/java/org/apache/commons/jexl3/parser/JexlParser.java +++ b/src/main/java/org/apache/commons/jexl3/parser/JexlParser.java @@ -32,6 +32,7 @@ import java.util.Set; import java.util.TreeMap; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Predicate; import org.apache.commons.jexl3.JexlEngine; import org.apache.commons.jexl3.JexlException; @@ -791,6 +792,13 @@ public abstract class JexlParser extends StringParser implements JexlScriptParse */ protected abstract Token getToken(int index); + /** + * Overridden in actual parser to replace the node on top of the tree-building stack. + * + * @param replacement The node replacing the top node + */ + protected abstract void jjtreeReplaceTop(JexlNode replacement); + /** * Gets the lexical unit used by this parser. * @@ -1005,6 +1013,14 @@ public abstract class JexlParser extends StringParser implements JexlScriptParse if (script.getScope() != scope) { script.setScope(scope); } + } else if (node instanceof ASTReference) { + final JexlNode antish = resolveAntish((ASTReference) node); + if (antish != node) { + // the whole reference is a declared antish variable + jjtreeReplaceTop(antish); + featureController.controlNode(antish); + return; + } } else if (ASSIGN_NODES.contains(node.getClass())) { final JexlNode lv = node.jjtGetChild(0); if (!lv.isLeftValue()) { @@ -1026,6 +1042,63 @@ public abstract class JexlParser extends StringParser implements JexlScriptParse featureController.controlNode(node); } + /** + * Resolves a declared antish variable in a reference. + * <p>When the features declare antish variable names, the longest dotted prefix satisfying the + * declaration test of a reference rooted on a global variable, like <code>x.y</code> in + * <code>x.y.z</code>, is rewritten as a single identifier.</p> + * + * @param reference The reference node + * @return the reference (rewritten or not) or the identifier if the whole reference is an antish variable + * @see JexlFeatures#antishTest(Predicate) + */ + private JexlNode resolveAntish(final ASTReference reference) { + final Predicate<String> declared = getFeatures().antishTest(); + if (declared == JexlFeatures.TEST_STR_FALSE) { + return reference; + } + final JexlNode first = reference.jjtGetChild(0); + // a local variable or a namespace can not be the root of an antish variable + if (!(first instanceof ASTIdentifier) + || ((ASTIdentifier) first).getSymbol() >= 0 + || ((ASTIdentifier) first).getNamespace() != null) { + return reference; + } + final ASTIdentifier root = (ASTIdentifier) first; + final int count = reference.jjtGetNumChildren(); + final StringBuilder name = new StringBuilder(root.getName()); + String antish = null; + int last = 0; + for (int c = 1; c < count; ++c) { + final JexlNode child = reference.jjtGetChild(c); + // only plain identifier access, no safe navigation nor expression + if (child.getClass() != ASTIdentifierAccess.class) { + break; + } + name.append('.').append(((ASTIdentifierAccess) child).getName()); + final String candidate = name.toString(); + if (declared.test(candidate)) { + antish = candidate; + last = c; + } + } + if (antish == null) { + return reference; + } + root.setSymbol(antish); + if (last == count - 1) { + root.jjtSetParent(null); + return root; + } + final JexlNode[] children = new JexlNode[count - last]; + children[0] = root; + for (int c = last + 1; c < count; ++c) { + children[c - last] = reference.jjtGetChild(c); + } + reference.jjtSetChildren(children); + return reference; + } + /** * Parses an embedded Jexl expression within an interpolation node. * <p>This creates a sub-parser that shares the scopes of the parent parser.</p> diff --git a/src/main/java/org/apache/commons/jexl3/parser/Parser.jjt b/src/main/java/org/apache/commons/jexl3/parser/Parser.jjt index 572b525d..a0e56ae9 100644 --- a/src/main/java/org/apache/commons/jexl3/parser/Parser.jjt +++ b/src/main/java/org/apache/commons/jexl3/parser/Parser.jjt @@ -59,6 +59,12 @@ public final class Parser extends JexlParser token.next = jj_nt = token_source.getNextToken(); } + @Override + protected void jjtreeReplaceTop(JexlNode replacement) { + jjtree.popNode(); + jjtree.pushNode(replacement); + } + /** * Parses a JEXL source string and returns the corresponding AST. * @param jexlInfo the JEXL info to use, may be null diff --git a/src/main/java/org/apache/commons/jexl3/parser/SimpleNode.java b/src/main/java/org/apache/commons/jexl3/parser/SimpleNode.java index 8caba16f..b912d660 100644 --- a/src/main/java/org/apache/commons/jexl3/parser/SimpleNode.java +++ b/src/main/java/org/apache/commons/jexl3/parser/SimpleNode.java @@ -191,7 +191,7 @@ public class SimpleNode implements Node { public void jjtOpen() { } - // For use by ASTJexlScript only + // For use by ASTJexlScript and the parser antish variable resolution only void jjtSetChildren(final JexlNode[] jexlNodes) { children = jexlNodes; } diff --git a/src/test/java/org/apache/commons/jexl3/AntishCallTest.java b/src/test/java/org/apache/commons/jexl3/AntishCallTest.java index 65d1ea31..14a130b6 100644 --- a/src/test/java/org/apache/commons/jexl3/AntishCallTest.java +++ b/src/test/java/org/apache/commons/jexl3/AntishCallTest.java @@ -17,6 +17,7 @@ package org.apache.commons.jexl3; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -170,6 +171,22 @@ class AntishCallTest extends JexlTestCase { runTestCall(JEXL, jc); } + @Test + void testLocalRootShadowsAntish() { + final JexlEvalContext ctxt = new JexlEvalContext(); + ctxt.set("x.y", 42); + // a global root resolves as an antish variable + assertEquals(42, JEXL.createScript("x.y").execute(ctxt)); + // a local variable root shadows the antish variable, when reading... + final JexlException.Variable xvar = assertThrows(JexlException.Variable.class, + () -> JEXL.createScript("var x; x.y").execute(ctxt)); + assertEquals("x", xvar.getVariable()); + assertFalse(xvar.isUndefined()); + // ...as when assigning + assertThrows(JexlException.Property.class, () -> JEXL.createScript("var x; x.y = 1").execute(ctxt)); + assertEquals(42, ctxt.get("x.y")); + } + // JEXL-300 @Test void testSafeAnt() throws Exception { @@ -205,4 +222,4 @@ class AntishCallTest extends JexlTestCase { assertThrows(JexlException.class, () -> script4.execute(ctxt), "x not defined"); } -} \ No newline at end of file +} diff --git a/src/test/java/org/apache/commons/jexl3/AntishFeaturesTest.java b/src/test/java/org/apache/commons/jexl3/AntishFeaturesTest.java new file mode 100644 index 00000000..dc4a5508 --- /dev/null +++ b/src/test/java/org/apache/commons/jexl3/AntishFeaturesTest.java @@ -0,0 +1,265 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.commons.jexl3; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.junit.jupiter.api.Test; + +/** + * Tests antish variables declared at parse time through {@link JexlFeatures#antishTest(java.util.function.Predicate)}. + */ +class AntishFeaturesTest extends JexlTestCase { + + public AntishFeaturesTest() { + super("AntishFeaturesTest"); + } + + private static JexlEngine createEngine(final String... names) { + final JexlFeatures features = JexlFeatures.createDefault().antishTest(JexlFeatures.namePatterns(Arrays.asList(names))); + return new JexlBuilder().features(features).strict(true).create(); + } + + private static Map<String, Object> map(final String key, final Object value) { + final Map<String, Object> map = new HashMap<>(); + map.put(key, value); + return map; + } + + @Test + void testRead() throws Exception { + final JexlEngine jexl = createEngine("x.y"); + final JexlContext ctxt = new MapContext(); + ctxt.set("x.y", map("z", 42)); + final JexlScript script = jexl.createScript("x.y.z"); + assertEquals(42, script.execute(ctxt)); + // x.y is one variable, z a property + final Set<List<String>> vars = script.getVariables(); + assertEquals(Collections.singleton(Arrays.asList("x.y", "z")), vars); + assertEquals("x.y.z", script.getParsedText()); + debuggerCheck(jexl); + } + + @Test + void testWholeReference() throws Exception { + final JexlEngine jexl = createEngine("x.y"); + final JexlContext ctxt = new MapContext(); + ctxt.set("x.y", 42); + assertEquals(42, jexl.createScript("x.y").execute(ctxt)); + assertEquals(43, jexl.createScript("x.y = x.y + 1").execute(ctxt)); + assertEquals(43, ctxt.get("x.y")); + assertEquals(44, jexl.createScript("x.y += 1").execute(ctxt)); + assertEquals(44, ctxt.get("x.y")); + debuggerCheck(jexl); + } + + @Test + void testAssignProperty() throws Exception { + final JexlEngine jexl = createEngine("x.y"); + final JexlContext ctxt = new MapContext(); + final Map<String, Object> xy = map("z", 1); + ctxt.set("x.y", xy); + assertEquals(2, jexl.createScript("x.y.z = 2").execute(ctxt)); + assertEquals(2, xy.get("z")); + debuggerCheck(jexl); + } + + @Test + void testLongestPrefix() throws Exception { + final JexlEngine jexl = createEngine("x.y", "x.y.z"); + final JexlContext ctxt = new MapContext(); + ctxt.set("x.y", map("z", map("w", 1))); + ctxt.set("x.y.z", map("w", 2)); + assertEquals(2, jexl.createScript("x.y.z.w").execute(ctxt)); + assertEquals(1, jexl.createScript("x.y['z'].w").execute(ctxt)); + debuggerCheck(jexl); + } + + @Test + void testPatterns() throws Exception { + final JexlEngine jexl = createEngine("build.*"); + final JexlContext ctxt = new MapContext(); + ctxt.set("build.version", "1.2"); + ctxt.set("build.dir", "target"); + assertEquals("target/1.2", jexl.createScript("build.dir + '/' + build.version").execute(ctxt)); + debuggerCheck(jexl); + } + + @Test + void testMethodCall() throws Exception { + final JexlEngine jexl = createEngine("x.y"); + final JexlContext ctxt = new MapContext(); + ctxt.set("x.y", Arrays.asList(1, 2, 3)); + assertEquals(3, jexl.createScript("x.y.size()").execute(ctxt)); + debuggerCheck(jexl); + } + + @Test + void testLocalRoot() throws Exception { + final JexlEngine jexl = createEngine("x.y"); + final JexlContext ctxt = new MapContext(); + ctxt.set("x.y", 42); + // a local variable is never the root of an antish variable + assertEquals(1, jexl.createScript("var x = { 'y' : 1 }; x.y").execute(ctxt)); + assertEquals(1, jexl.createScript("x.y", "x").execute(ctxt, map("y", 1))); + debuggerCheck(jexl); + } + + @Test + void testSafeNavigationAndExpression() throws Exception { + final JexlEngine jexl = createEngine("x.y"); + final JexlEvalContext ctxt = new JexlEvalContext(); + ctxt.set("x.y", 42); + ctxt.set("x", map("y", 1)); + assertEquals(42, jexl.createScript("x.y").execute(ctxt)); + // safe navigation or expression segments are not rewritten, y is a property of x + assertEquals(1, jexl.createScript("x?.y").execute(ctxt)); + assertEquals(1, jexl.createScript("x.`y`").execute(ctxt)); + debuggerCheck(jexl); + } + + @Test + void testUndeclared() { + final JexlEngine jexl = createEngine("x.y"); + final JexlEvalContext ctxt = new JexlEvalContext(); + ctxt.set("a.b", 42); + // not declared, not resolved: declaring antish names disables runtime antish + assertThrows(JexlException.Property.class, () -> jexl.createScript("a.b").execute(ctxt)); + // declared but undefined + final JexlException.Variable xvar = assertThrows(JexlException.Variable.class, () -> jexl.createScript("x.y").execute(ctxt)); + assertEquals("x.y", xvar.getVariable()); + assertTrue(xvar.isUndefined()); + } + + @Test + void testPartialDeclaration() { + final JexlEngine jexl = createEngine("x.y"); + final JexlEvalContext ctxt = new JexlEvalContext(); + // even if the context options ask for it + ctxt.getEngineOptions().setAntish(true); + ctxt.set("x.y.z", 42); + // x.y is the declared variable, z its property; x.y.z is never solved at runtime + final JexlException.Property xp = assertThrows(JexlException.Property.class, () -> jexl.createScript("x.y.z").execute(ctxt)); + assertEquals(".z", xp.getProperty()); + // same with the engine options + final JexlContext mctxt = new MapContext(); + mctxt.set("x.y.z", 42); + assertThrows(JexlException.Property.class, () -> jexl.createScript("x.y.z").execute(mctxt)); + } + + @Test + void testSideEffectGlobal() { + final JexlFeatures features = JexlFeatures.createDefault() + .sideEffectGlobal(false) + .antishTest(JexlFeatures.namePatterns(Collections.singletonList("x.y"))); + final JexlEngine jexl = new JexlBuilder().features(features).create(); + // an antish variable is a global variable + assertThrows(JexlException.Feature.class, () -> jexl.createScript("x.y = 1")); + assertThrows(JexlException.Feature.class, () -> jexl.createScript("x.y += 1")); + assertThrows(JexlException.Feature.class, () -> jexl.createScript("x = 1")); + // assigning a property of a global value is not assigning a global + final JexlContext ctxt = new MapContext(); + final Map<String, Object> xy = map("z", 0); + ctxt.set("x.y", xy); + ctxt.set("x", new HashMap<>()); + assertEquals(43, jexl.createScript("x.y.z = 43").execute(ctxt)); + assertEquals(43, xy.get("z")); + assertEquals(44, jexl.createScript("x.y.z += 1").execute(ctxt)); + assertEquals(1, jexl.createScript("x.w = 1").execute(ctxt)); + assertEquals(1, ((Map<?, ?>) ctxt.get("x")).get("w")); + // without declared antish names, any property assignment on a global may create an antish variable + final JexlEngine jexl0 = new JexlBuilder().features(JexlFeatures.createDefault().sideEffectGlobal(false)).create(); + assertThrows(JexlException.Feature.class, () -> jexl0.createScript("x.y.z = 43")); + } + + @Test + void testSideEffectGlobalScripts() { + final JexlFeatures features = JexlFeatures.createDefault().sideEffectGlobal(false); + final JexlEngine jexl0 = new JexlBuilder().features(new JexlFeatures(features)).create(); + final JexlEngine jexl = new JexlBuilder() + .features(features.antishTest(JexlFeatures.namePatterns(Collections.singletonList("x.y")))) + .create(); + // assigning a global: always fails + final String[] globals = { + "x = 1", "x += 1", "x++", "--x", "x.y = 1", "x.y -= 1", "x.y++", "++x.y", "if (true) x.y = 4", "4 + (x.y = 1)" + }; + // assigning a property of a global value: only fails without declared antish names + final String[] properties = { + "x.y.z = 1", "x.y.z *= 2", "x.y.z++", "--x.y.z", "x.y['z'] = 1", "x.y[0] = 1", "x['y'] = 1", "x[0] = 1", + "x.w = 1", "x?.y = 1", "x.`y` = 1", "x().y = 1", "if (true) x.y.z = 4", "4 + (x.y.z = 1)" + }; + for (final String src : globals) { + assertThrows(JexlException.Feature.class, () -> jexl.createScript(src), src); + assertThrows(JexlException.Feature.class, () -> jexl0.createScript(src), src); + } + for (final String src : properties) { + assertNotNull(jexl.createScript(src), src); + assertThrows(JexlException.Feature.class, () -> jexl0.createScript(src), src); + } + // with a local root, nothing is global + for (final String src : globals) { + assertNotNull(jexl.createScript("var x = {}; " + src), src); + assertNotNull(jexl0.createScript("var x = {}; " + src), src); + } + // reading is never a side effect + assertNotNull(jexl.createScript("x.y.z + x.y + x")); + assertNotNull(jexl0.createScript("x.y.z + x.y + x")); + } + + @Test + void testScriptFeatures() { + // engine without declared antish names, runtime antish solving + final JexlEngine jexl = new JexlBuilder().strict(true).create(); + final JexlFeatures features = JexlFeatures.createDefault() + .sideEffectGlobal(false) + .antishTest(JexlFeatures.namePatterns(Collections.singletonList("x.y"))); + final JexlContext ctxt = new MapContext(); + ctxt.set("a.b.c", 42); + // a script parsed with declared antish names does not solve antish variables at runtime + final JexlScript assign = jexl.createScript(features, null, "a.b = 1"); + assertThrows(JexlException.class, () -> assign.execute(ctxt)); + assertFalse(ctxt.has("a.b")); + final JexlScript read = jexl.createScript(features, null, "a.b.c"); + assertThrows(JexlException.class, () -> read.execute(ctxt)); + // the engine still solves antish variables for its own scripts + assertEquals(42, jexl.createScript("a.b.c").execute(ctxt)); + } + + @Test + void testFeaturesEquality() { + final JexlFeatures f0 = JexlFeatures.createDefault().antishTest(JexlFeatures.namePatterns(Arrays.asList("x.y", "a.*"))); + final JexlFeatures f1 = JexlFeatures.createDefault().antishTest(JexlFeatures.namePatterns(Arrays.asList("a.*", "x.y"))); + final JexlFeatures f2 = JexlFeatures.createDefault().antishTest(JexlFeatures.namePatterns(Arrays.asList("x.y"))); + assertEquals(f0, f1); + assertEquals(f0.hashCode(), f1.hashCode()); + assertEquals(f0, new JexlFeatures(f0)); + assertNotEquals(f0, f2); + assertNotEquals(f0, JexlFeatures.createDefault()); + } +} diff --git a/src/test/java/org/apache/commons/jexl3/InterpreterCharacterizationTest.java b/src/test/java/org/apache/commons/jexl3/InterpreterCharacterizationTest.java new file mode 100644 index 00000000..5a701f9d --- /dev/null +++ b/src/test/java/org/apache/commons/jexl3/InterpreterCharacterizationTest.java @@ -0,0 +1,214 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.commons.jexl3; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; + +import org.junit.jupiter.api.Test; + +/** + * Characterization tests pinning the current behavior of loops, catch, lexical blocks + * and antish variable resolution/assignment in the interpreter. + * <p>Each case is {mode, script, expected} where expected is either "= value" or + * "ExceptionSimpleName: @line:col message".</p> + */ +class InterpreterCharacterizationTest extends JexlTestCase { + + private static final String[][] CASES = { + {"lex", "var s = 0; for (var i = 0; i < 10; ++i) { if (i == 2) continue; if (i == 5) break; s += i; } s", "= 8"}, + {"nolex", "var s = 0; for (var i = 0; i < 10; ++i) { if (i == 2) continue; if (i == 5) break; s += i; } s", "= 8"}, + {"lex", "var s = 0; for (var i = 0, j = 5; i < j; ++i) { s += i; } s", "= 10"}, + {"nolex", "var s = 0; for (var i = 0, j = 5; i < j; ++i) { s += i; } s", "= 10"}, + {"lex", "var i = 0; var s = 0; do { i++; if (i == 2) continue; if (i == 4) break; s += i; } while (i < 10); s", "= 4"}, + {"nolex", "var i = 0; var s = 0; do { i++; if (i == 2) continue; if (i == 4) break; s += i; } while (i < 10); s", "= 4"}, + {"lex", "var i = 0; var s = 0; while (i < 5) { i++; if (i % 2 == 0) continue; s += i; } s", "= 9"}, + {"nolex", "var i = 0; var s = 0; while (i < 5) { i++; if (i % 2 == 0) continue; s += i; } s", "= 9"}, + {"lex", "var s = 0; for (var x : [1, 2, 3]); s", "= 0"}, + {"nolex", "var s = 0; for (var x : [1, 2, 3]); s", "= 0"}, + {"lex", "var s = 0; for (var x : [1, 2, 3, 4]) { if (x == 2) continue; if (x == 4) break; s += x; } s", "= 4"}, + {"nolex", "var s = 0; for (var x : [1, 2, 3, 4]) { if (x == 2) continue; if (x == 4) break; s += x; } s", "= 4"}, + {"lex", "var s = 0; for (x : [1, 2, 3]) { s += x; } s + x", "= 9"}, + {"nolex", "var s = 0; for (x : [1, 2, 3]) { s += x; } s + x", "= 9"}, + {"lex", "var s = 0; for (let x : [1, 2, 3]) { s += x; } s", "= 6"}, + {"nolex", "var s = 0; for (let x : [1, 2, 3]) { s += x; } s", "= 6"}, + {"lex", "var s = 0; var k = 3; switch (k) { case 1: s = 1; break; case 3: s = 3; break; default: s = 9; } s", "= 3"}, + {"nolex", "var s = 0; var k = 3; switch (k) { case 1: s = 1; break; case 3: s = 3; break; default: s = 9; } s", "= 3"}, + {"lex", "try { throw 'a' } catch (e) { e }", "= Throw"}, + {"nolex", "try { throw 'a' } catch (e) { e }", "= Throw"}, + {"lex", "try { throw 'a' } catch (var e) { e }", "= Throw"}, + {"nolex", "try { throw 'a' } catch (var e) { e }", "= Throw"}, + {"lex", "try { throw 'a' } catch (let e) { e }", "= Throw"}, + {"nolex", "try { throw 'a' } catch (let e) { e }", "= Throw"}, + {"lex", "let e = 1; try { throw 'a' } catch (let e) { e }", "Parsing: @1:41 parsing error in 'e: variable is already declared'"}, + {"nolex", "let e = 1; try { throw 'a' } catch (let e) { e }", "Parsing: @1:41 parsing error in 'e: variable is already declared'"}, + {"lex", "try { throw 'a' } catch (e) { 1 }; e", "= Throw"}, + {"nolex", "try { throw 'a' } catch (e) { 1 }; e", "= Throw"}, + {"lex", "var r = 0; { let y = 2; r = y; } r", "= 2"}, + {"nolex", "var r = 0; { let y = 2; r = y; } r", "= 2"}, + {"lex", "let x = 1; { let x = 2; }", "Parsing: @1:18 parsing error in 'x: variable is already declared'"}, + {"nolex", "let x = 1; { let x = 2; }", "Parsing: @1:18 parsing error in 'x: variable is already declared'"}, + {"ant", "x.y.z", "Property: @1:5 undefined property 'z'"}, + {"noant", "x.y.z", "Property: @1:1 null value property '.y'"}, + {"strict", "x.y.z", "Property: @1:5 undefined property 'z'"}, + {"safe", "x.y.z", "Property: @1:5 undefined property 'z'"}, + {"ant", "x.y.z = 3", "Property: @1:7 undefined property 'z'"}, + {"noant", "x.y.z = 3", "JexlException: @1:1 JEXL error : illegal assignment form"}, + {"strict", "x.y.z = 3", "Property: @1:7 undefined property 'z'"}, + {"safe", "x.y.z = 3", "Property: @1:7 undefined property 'z'"}, + {"ant", "x.y.z += 2", "Property: @1:7 undefined property 'z'"}, + {"noant", "x.y.z += 2", "JexlException: @1:1 JEXL error : illegal assignment form"}, + {"strict", "x.y.z += 2", "Property: @1:7 undefined property 'z'"}, + {"safe", "x.y.z += 2", "Property: @1:7 undefined property 'z'"}, + {"ant", "x.y?.z.w = 3", "Property: @1:7 undefined property '<null>.<?>'"}, + {"noant", "x.y?.z.w = 3", "JexlException: @1:1 JEXL error : illegal assignment form"}, + {"strict", "x.y?.z.w = 3", "Property: @1:7 undefined property '<null>.<?>'"}, + {"safe", "x.y?.z.w = 3", "Property: @1:7 undefined property '<null>.<?>'"}, + {"ant", "x.y.`'z'`.w = 3", "Property: @1:10 undefined property ''z''"}, + {"noant", "x.y.`'z'`.w = 3", "JexlException: @1:1 JEXL error : illegal assignment form"}, + {"strict", "x.y.`'z'`.w = 3", "Property: @1:10 undefined property ''z''"}, + {"safe", "x.y.`'z'`.w = 3", "Property: @1:10 undefined property ''z''"}, + {"ant", "x.y.size()", "= 2"}, + {"noant", "x.y.size()", "Property: @1:1 null value property '.y'"}, + {"strict", "x.y.size()", "= 2"}, + {"safe", "x.y.size()", "= 2"}, + {"ant", "x.y.foo()", "Method: @1:4 unsolvable function/method 'foo'"}, + {"noant", "x.y.foo()", "Property: @1:1 null value property '.y'"}, + {"strict", "x.y.foo()", "Method: @1:4 unsolvable function/method 'foo'"}, + {"safe", "x.y.foo()", "= null"}, + {"ant", "x.a.b", "Variable: @1:1 variable 'x.a.b' is undefined"}, + {"noant", "x.a.b", "Property: @1:1 null value property '.a'"}, + {"strict", "x.a.b", "Variable: @1:1 variable 'x.a.b' is undefined"}, + {"safe", "x.a.b", "= null"}, + {"ant", "x.y.z.w", "Property: @1:6 undefined property 'z'"}, + {"noant", "x.y.z.w", "Property: @1:1 null value property '.y'"}, + {"strict", "x.y.z.w", "Property: @1:6 undefined property 'z'"}, + {"safe", "x.y.z.w", "Property: @1:6 undefined property 'z'"}, + {"ant", "var x; x.y.z", "Variable: @1:8 variable 'x' is null"}, + {"noant", "var x; x.y.z", "Property: @1:8 null value property '.y'"}, + {"strict", "var x; x.y.z", "Variable: @1:8 variable 'x' is null"}, + {"safe", "var x; x.y.z", "= null"}, + {"ant", "var x; x.y.z = 1", "Property: @1:8 undefined property '<null>.<?>'"}, + {"noant", "var x; x.y.z = 1", "JexlException: @1:8 JEXL error : illegal assignment form"}, + {"strict", "var x; x.y.z = 1", "Property: @1:8 undefined property '<null>.<?>'"}, + {"safe", "var x; x.y.z = 1", "Property: @1:8 undefined property '<null>.<?>'"}, + {"ant", "m.y = 3; m.y", "= 3"}, + {"noant", "m.y = 3; m.y", "= 3"}, + {"strict", "m.y = 3; m.y", "= 3"}, + {"safe", "m.y = 3; m.y", "= 3"}, + {"ant", "m.y.z = 3", "JexlException: @1:4 JEXL error : illegal assignment form"}, + {"noant", "m.y.z = 3", "JexlException: @1:4 JEXL error : illegal assignment form"}, + {"strict", "m.y.z = 3", "JexlException: @1:4 JEXL error : illegal assignment form"}, + {"safe", "m.y.z = 3", "JexlException: @1:4 JEXL error : illegal assignment form"}, + {"ant", "m.q.z", "Property: @1:1 undefined property '.q'"}, + {"noant", "m.q.z", "Property: @1:1 undefined property '.q'"}, + {"strict", "m.q.z", "Property: @1:1 undefined property '.q'"}, + {"safe", "m.q.z", "= null"}, + {"ant", "x.y[0]", "= 1"}, + {"noant", "x.y[0]", "Property: @1:1 null value property '.y'"}, + {"strict", "x.y[0]", "= 1"}, + {"safe", "x.y[0]", "= 1"}, + {"ant", "x.y[0] = 1", "= 1"}, + {"noant", "x.y[0] = 1", "JexlException: @1:1 JEXL error : illegal assignment form"}, + {"strict", "x.y[0] = 1", "= 1"}, + {"safe", "x.y[0] = 1", "= 1"}, + {"ant", "(x.y).z", "Property: @1:7 undefined property 'z'"}, + {"noant", "(x.y).z", "Property: @1:2 null value property '.y'"}, + {"strict", "(x.y).z", "Property: @1:7 undefined property 'z'"}, + {"safe", "(x.y).z", "Property: @1:7 undefined property 'z'"}, + {"ant", "x.y.z.size()", "Property: @1:6 undefined property 'z'"}, + {"noant", "x.y.z.size()", "Property: @1:1 null value property '.y'"}, + {"strict", "x.y.z.size()", "Property: @1:6 undefined property 'z'"}, + {"safe", "x.y.z.size()", "Property: @1:6 undefined property 'z'"}, + {"ant", "a.b.c?.d", "= null"}, + {"noant", "a.b.c?.d", "Property: @1:1 null value property '.b'"}, + {"strict", "a.b.c?.d", "= null"}, + {"safe", "a.b.c?.d", "= null"}, + {"ant", "a.b?.c.d", "Variable: @1:1 variable 'a.b' is undefined"}, + {"noant", "a.b?.c.d", "= null"}, + {"strict", "a.b?.c.d", "Variable: @1:1 variable 'a.b' is undefined"}, + {"safe", "a.b?.c.d", "= null"}, + {"ant", "nul.y", "Variable: @1:1 variable 'nul.y' is undefined"}, + {"noant", "nul.y", "Property: @1:1 null value property '.y'"}, + {"strict", "nul.y", "Variable: @1:1 variable 'nul.y' is undefined"}, + {"safe", "nul.y", "= null"}, + {"ant", "nul.y.z", "Variable: @1:1 variable 'nul.y.z' is undefined"}, + {"noant", "nul.y.z", "Property: @1:1 null value property '.y'"}, + {"strict", "nul.y.z", "Variable: @1:1 variable 'nul.y.z' is undefined"}, + {"safe", "nul.y.z", "= null"}, + {"ant", "nul.y = 1", "= 1"}, + {"noant", "nul.y = 1", "JexlException: @1:1 JEXL error : illegal assignment form"}, + {"strict", "nul.y = 1", "= 1"}, + {"safe", "nul.y = 1", "= 1"}, + }; + + public InterpreterCharacterizationTest() { + super("InterpreterCharacterizationTest"); + } + + /** + * Creates a context for a given mode. + * <p>The context carries antish variables {@code x.y.z}, {@code x.y}, {@code a.b.c} + * and the globals {@code m} (a map) and {@code nul} (null).</p> + * + * @param mode the mode + * @return the context + */ + private static JexlEvalContext createContext(final String mode) { + final JexlEvalContext ctxt = new JexlEvalContext(); + ctxt.set("x.y.z", 40); + ctxt.set("x.y", new ArrayList<>(Arrays.asList(1, 2))); + ctxt.set("a.b.c", null); + ctxt.set("m", new HashMap<String, Object>()); + ctxt.set("nul", null); + final JexlOptions options = ctxt.getEngineOptions(); + switch (mode) { + case "nolex": options.setLexical(false); break; + case "noant": options.setAntish(false); break; + case "strict": options.setStrict(true); break; + case "safe": options.setSafe(true); break; + default: break; + } + return ctxt; + } + + private String run(final String src, final JexlContext ctxt) { + try { + final Object o = JEXL.createScript(src).execute(ctxt); + return o instanceof Throwable ? "= " + o.getClass().getSimpleName() : "= " + o; + } catch (final JexlException x) { + final String m = x.getMessage(); + return x.getClass().getSimpleName() + ": " + m.substring(m.indexOf('@')); + } + } + + @Test + void testCharacterization() { + final List<String> failures = new ArrayList<>(); + for (final String[] c : CASES) { + final String actual = run(c[1], createContext(c[0])); + if (!c[2].equals(actual)) { + failures.add("[" + c[0] + "] " + c[1] + "\n expected: " + c[2] + "\n actual: " + actual); + } + } + assertEquals("", String.join("\n", failures)); + } +} diff --git a/src/test/java/org/apache/commons/jexl3/benchmark/InterpreterBenchmark.java b/src/test/java/org/apache/commons/jexl3/benchmark/InterpreterBenchmark.java new file mode 100644 index 00000000..ec9d5f77 --- /dev/null +++ b/src/test/java/org/apache/commons/jexl3/benchmark/InterpreterBenchmark.java @@ -0,0 +1,142 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.commons.jexl3.benchmark; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import org.apache.commons.jexl3.JexlBuilder; +import org.apache.commons.jexl3.JexlContext; +import org.apache.commons.jexl3.JexlEngine; +import org.apache.commons.jexl3.JexlFeatures; +import org.apache.commons.jexl3.JexlScript; +import org.apache.commons.jexl3.MapContext; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.Warmup; + +/** + * Interpreter hot-path benchmarks: property access, method calls, loops, lexical blocks, + * assignments and antish variables (resolved at runtime or declared at parse time). + * <p>Run with: {@code mvn clean test -Pbenchmark -Dbenchmark=InterpreterBenchmark}</p> + */ +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(TimeUnit.MICROSECONDS) +@State(Scope.Benchmark) +@Warmup(iterations = 5, time = 1) +@Measurement(iterations = 5, time = 1) +@Fork(1) +public class InterpreterBenchmark { + + private JexlContext context; + private JexlScript propertyAccess; + private JexlScript methodCall; + private JexlScript forEachLoop; + private JexlScript forLoop; + private JexlScript whileLoop; + private JexlScript lexicalBlocks; + private JexlScript assignments; + private JexlScript antish; + private JexlScript antishDeclared; + + @Setup + public void setup() { + final JexlEngine jexl = new JexlBuilder() + .features(JexlFeatures.createDefault()) + .cache(64) + .create(); + final Map<String, Object> map = new HashMap<>(); + map.put("a", 1); + map.put("b", new HashMap<>(map)); + final List<Integer> list = new ArrayList<>(); + for (int i = 0; i < 100; ++i) { + list.add(i); + } + context = new MapContext(); + context.set("map", map); + context.set("list", list); + context.set("x.y.z", 1); + propertyAccess = jexl.createScript("var s = 0; for (var i : list) { s += map.a + map.b.a; } s"); + methodCall = jexl.createScript("var s = 0; for (var i : list) { s += list.size() + list.get(i); } s"); + forEachLoop = jexl.createScript("var s = 0; for (var i : list) { if (i % 2 == 0) continue; if (i > 90) break; s += i; } s"); + forLoop = jexl.createScript("var s = 0; for (var i = 0; i < 100; ++i) { if (i % 2 == 0) continue; s += i; } s"); + whileLoop = jexl.createScript("var s = 0; var i = 0; while (i < 100) { i += 1; if (i % 2 == 0) continue; s += i; } s"); + lexicalBlocks = jexl.createScript("var s = 0; for (let i : list) { let j = i; { let k = j; s += k; } } s"); + assignments = jexl.createScript("var s = 0; for (var i : list) { map.c = i; map['d'] = i; s += map.c; } s"); + final String antishSrc = "var s = 0; for (var i : list) { x.y.w = i; s += x.y.z + x.y.w; } s"; + antish = jexl.createScript(antishSrc); + final JexlFeatures declared = JexlFeatures.createDefault() + .antishTest(JexlFeatures.namePatterns(Arrays.asList("x.y.z", "x.y.w"))); + antishDeclared = new JexlBuilder().features(declared).cache(64).create().createScript(antishSrc); + } + + @Benchmark + public Object propertyAccess() { + return propertyAccess.execute(context); + } + + @Benchmark + public Object methodCall() { + return methodCall.execute(context); + } + + @Benchmark + public Object forEachLoop() { + return forEachLoop.execute(context); + } + + @Benchmark + public Object forLoop() { + return forLoop.execute(context); + } + + @Benchmark + public Object whileLoop() { + return whileLoop.execute(context); + } + + @Benchmark + public Object lexicalBlocks() { + return lexicalBlocks.execute(context); + } + + @Benchmark + public Object antishDeclared() { + return antishDeclared.execute(context); + } + + @Benchmark + public Object assignments() { + return assignments.execute(context); + } + + @Benchmark + public Object antish() { + return antish.execute(context); + } +}
