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);
+    }
+}

Reply via email to