This is an automated email from the ASF dual-hosted git repository.

ddekany pushed a commit to branch FREEMARKER-183
in repository https://gitbox.apache.org/repos/asf/freemarker.git

commit ba05292bbfc6338e193054044d70078d00c63211
Author: ddekany <[email protected]>
AuthorDate: Mon Jan 8 09:59:42 2024 +0100

    Added support for marking obj.prop and obj.prop() to be the same in 
templates. Made Java zero argument methods to be such properties by default, if 
incompatibleImprovements is at least 2.3.33.
---
 build.gradle.kts                                   |   2 +-
 .../src/main/java/freemarker/core/Dot.java         |  21 ++-
 .../java/freemarker/core/DotBeforeMethodCall.java  |  58 +++++++
 .../main/java/freemarker/ext/beans/APIModel.java   |   2 +-
 .../ext/beans/{APIModel.java => APIModelEx.java}   |  30 ++--
 .../main/java/freemarker/ext/beans/BeanModel.java  |  97 ++++++++++-
 .../java/freemarker/ext/beans/BeansWrapper.java    | 124 ++++++++++++--
 .../ext/beans/BeansWrapperConfiguration.java       |  30 ++++
 .../freemarker/ext/beans/ClassIntrospector.java    |  72 +++++++-
 .../ext/beans/ClassIntrospectorBuilder.java        |  48 +++++-
 .../ext/beans/FastPropertyDescriptor.java          |  18 +-
 .../ext/beans/MethodAppearanceFineTuner.java       |  26 +++
 .../beans/MethodCallAwareTemplateHashModel.java    | 115 +++++++++++++
 .../java/freemarker/ext/beans/StringModel.java     |  11 +-
 .../java/freemarker/ext/beans/StringModelEx.java   |  53 ++++++
 ...l.java => ZeroArgumentNonVoidMethodPolicy.java} |  38 ++---
 .../main/java/freemarker/ext/beans/_BeansAPI.java  |   4 +-
 .../src/main/javacc/freemarker/core/FTL.jj         |   3 +
 .../beans/TestZeroArgumentNonVoidMethodPolicy.java | 187 +++++++++++++++++++++
 .../template/DefaultObjectWrapperTest.java         |   4 +-
 20 files changed, 856 insertions(+), 87 deletions(-)

diff --git a/build.gradle.kts b/build.gradle.kts
index 6e6f1f93..4114dec8 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -57,7 +57,7 @@ freemarkerRoot {
     configureSourceSet("jython20")
     configureSourceSet("jython22")
     configureSourceSet("jython25") { enableTests() }
-    configureSourceSet("core16", "16")
+    configureSourceSet("core16", "16") { enableTests() }
 }
 
 val compileJavacc = 
tasks.register<freemarker.build.CompileJavaccTask>("compileJavacc") {
diff --git a/freemarker-core/src/main/java/freemarker/core/Dot.java 
b/freemarker-core/src/main/java/freemarker/core/Dot.java
index 54bae57b..712945cc 100644
--- a/freemarker-core/src/main/java/freemarker/core/Dot.java
+++ b/freemarker-core/src/main/java/freemarker/core/Dot.java
@@ -27,7 +27,7 @@ import freemarker.template.TemplateModel;
  * The dot operator. Used to reference items inside a
  * <code>TemplateHashModel</code>.
  */
-final class Dot extends Expression {
+class Dot extends Expression {
     private final Expression target;
     private final String key;
 
@@ -36,11 +36,20 @@ final class Dot extends Expression {
         this.key = key;
     }
 
+    /**
+     * Shallow copy constructor
+     */
+    public Dot(Dot dot) {
+        this(dot.target, dot.key);
+        this.constantValue = dot.constantValue; // Probably always will be 
null here
+        copyFieldsFrom(dot);
+    }
+
     @Override
     TemplateModel _eval(Environment env) throws TemplateException {
         TemplateModel leftModel = target.eval(env);
         if (leftModel instanceof TemplateHashModel) {
-            return ((TemplateHashModel) leftModel).get(key);
+            return evalOnHash((TemplateHashModel) leftModel);
         }
         if (leftModel == null && env.isClassicCompatible()) {
             return null; // ${noSuchVar.foo} has just printed nothing in FM 1.
@@ -48,6 +57,14 @@ final class Dot extends Expression {
         throw new NonHashException(target, leftModel, env);
     }
 
+    protected TemplateModel evalOnHash(TemplateHashModel leftModel) throws 
TemplateException {
+        return leftModel.get(key);
+    }
+
+    public String getKey() {
+        return key;
+    }
+
     @Override
     public String getCanonicalForm() {
         return target.getCanonicalForm() + getNodeTypeSymbol() + 
_CoreStringUtils.toFTLIdentifierReferenceAfterDot(key);
diff --git 
a/freemarker-core/src/main/java/freemarker/core/DotBeforeMethodCall.java 
b/freemarker-core/src/main/java/freemarker/core/DotBeforeMethodCall.java
new file mode 100644
index 00000000..e2758c1c
--- /dev/null
+++ b/freemarker-core/src/main/java/freemarker/core/DotBeforeMethodCall.java
@@ -0,0 +1,58 @@
+/*
+ * 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
+ *
+ *   http://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 freemarker.core;
+
+import freemarker.ext.beans.BeansWrapper;
+import freemarker.ext.beans.MethodCallAwareTemplateHashModel;
+import freemarker.template.TemplateException;
+import freemarker.template.TemplateHashModel;
+import freemarker.template.TemplateModel;
+
+/**
+ * Like {@link Dot}, but when used before method call (but as of 2.3.33, 
before 0-argument calls only), as in
+ * {@code obj.key()}. The reason it's only used before 0-argument calls (as of 
2.3.33 at least) is that it adds some
+ * overhead, and this was only added for the
+ * {@link 
BeansWrapper.MethodAppearanceDecision#setMethodInsteadOfPropertyValueBeforeCall(boolean)}
 feature, which is
+ * always used for 0-argument calls. We don't necessarily want to go beyond 
that hack, as we don't have a proper
+ * method namespace separation in the template language.
+ */
+class DotBeforeMethodCall extends Dot {
+    public DotBeforeMethodCall(Dot dot) {
+        super(dot);
+    }
+
+    @Override
+    protected TemplateModel evalOnHash(TemplateHashModel leftModel) throws 
TemplateException {
+        if (leftModel instanceof MethodCallAwareTemplateHashModel) {
+            try {
+                return ((MethodCallAwareTemplateHashModel) 
leftModel).getBeforeMethodCall(getKey());
+            } catch 
(MethodCallAwareTemplateHashModel.ShouldNotBeGetAsMethodException e) {
+                String hint = e.getHint();
+                throw new NonMethodException(
+                        this,
+                        e.getActualValue(),
+                        hint != null ? new String[] { hint } : null,
+                        Environment.getCurrentEnvironment());
+            }
+        } else {
+            return super.evalOnHash(leftModel);
+        }
+    }
+}
diff --git a/freemarker-core/src/main/java/freemarker/ext/beans/APIModel.java 
b/freemarker-core/src/main/java/freemarker/ext/beans/APIModel.java
index 4580ac8d..779cda55 100644
--- a/freemarker-core/src/main/java/freemarker/ext/beans/APIModel.java
+++ b/freemarker-core/src/main/java/freemarker/ext/beans/APIModel.java
@@ -32,7 +32,7 @@ package freemarker.ext.beans;
  * 
  * @since 2.3.22
  */
-final class APIModel extends BeanModel {
+class APIModel extends BeanModel {
 
     APIModel(Object object, BeansWrapper wrapper) {
         super(object, wrapper, false);
diff --git a/freemarker-core/src/main/java/freemarker/ext/beans/APIModel.java 
b/freemarker-core/src/main/java/freemarker/ext/beans/APIModelEx.java
similarity index 59%
copy from freemarker-core/src/main/java/freemarker/ext/beans/APIModel.java
copy to freemarker-core/src/main/java/freemarker/ext/beans/APIModelEx.java
index 4580ac8d..e61837c2 100644
--- a/freemarker-core/src/main/java/freemarker/ext/beans/APIModel.java
+++ b/freemarker-core/src/main/java/freemarker/ext/beans/APIModelEx.java
@@ -19,27 +19,23 @@
 
 package freemarker.ext.beans;
 
+import freemarker.template.TemplateMethodModelEx;
+import freemarker.template.TemplateModelException;
+
 /**
- * Exposes the Java API (and properties) of an object.
- * 
- * <p>
- * Notes:
- * <ul>
- * <li>The exposing level is inherited from the {@link BeansWrapper}</li>
- * <li>But methods will always shadow properties and fields with identical 
name, regardless of {@link BeansWrapper}
- * settings</li>
- * </ul>
- * 
- * @since 2.3.22
+ * Like {@link APIModel}, but adds support for {@link 
MethodCallAwareTemplateHashModel}.
+ *
+ * @since 2.3.33
  */
-final class APIModel extends BeanModel {
+final class APIModelEx extends APIModel implements 
MethodCallAwareTemplateHashModel {
 
-    APIModel(Object object, BeansWrapper wrapper) {
-        super(object, wrapper, false);
+    APIModelEx(Object object, BeansWrapper wrapper) {
+        super(object, wrapper);
     }
 
-    protected boolean isMethodsShadowItems() {
-        return true;
+    @Override
+    public TemplateMethodModelEx getBeforeMethodCall(String key) throws 
TemplateModelException,
+            ShouldNotBeGetAsMethodException {
+        return super.getBeforeMethodCall(key);
     }
-    
 }
diff --git a/freemarker-core/src/main/java/freemarker/ext/beans/BeanModel.java 
b/freemarker-core/src/main/java/freemarker/ext/beans/BeanModel.java
index 79110b90..6601e353 100644
--- a/freemarker-core/src/main/java/freemarker/ext/beans/BeanModel.java
+++ b/freemarker-core/src/main/java/freemarker/ext/beans/BeanModel.java
@@ -30,6 +30,7 @@ import java.util.List;
 import java.util.Map;
 import java.util.Set;
 
+import freemarker.core.BugException;
 import freemarker.core.CollectionAndSequence;
 import freemarker.core._DelayedFTLTypeDescription;
 import freemarker.core._DelayedJQuote;
@@ -43,11 +44,13 @@ import freemarker.template.SimpleScalar;
 import freemarker.template.SimpleSequence;
 import freemarker.template.TemplateCollectionModel;
 import freemarker.template.TemplateHashModelEx;
+import freemarker.template.TemplateMethodModelEx;
 import freemarker.template.TemplateModel;
 import freemarker.template.TemplateModelException;
 import freemarker.template.TemplateModelIterator;
 import freemarker.template.TemplateModelWithAPISupport;
 import freemarker.template.TemplateScalarModel;
+import freemarker.template.utility.CollectionUtils;
 import freemarker.template.utility.StringUtil;
 
 /**
@@ -134,12 +137,67 @@ implements TemplateHashModelEx, AdapterTemplateModel, 
WrapperTemplateModel, Temp
      * then {@code non-void-return-type get(java.lang.Object)}, or 
      * alternatively (if the wrapped object is a resource bundle) 
      * {@code Object getObject(java.lang.String)}.
+     *
+     * <p>As of 2.3.33, the default implementation of this method delegates to 
{@link #get(String, boolean)}. It's
+     * better to override that, instead of this method. Otherwise, unwanted 
behavior can arise if the model class also
+     * implements {@link MethodCallAwareTemplateHashModel}, as that will 
certainly call {@link #get(String, boolean)}
+     * internally, and not the overridden version of this method.
+     *
      * @throws TemplateModelException if there was no property nor method nor
      * a generic {@code get} method to invoke.
      */
     @Override
-    public TemplateModel get(String key)
-        throws TemplateModelException {
+    public TemplateModel get(String key) throws TemplateModelException {
+        try {
+            return get(key, false);
+        } catch 
(MethodCallAwareTemplateHashModel.ShouldNotBeGetAsMethodException e) {
+            throw new BugException(e);
+        }
+    }
+
+    /**
+     * Can be overridden to be public, to implement {@link 
MethodCallAwareTemplateHashModel}. We don't implement that
+     * in {@link BeanModel} for backward compatibility, but the functionality 
is present. If you expose this method by
+     * implementing {@link MethodCallAwareTemplateHashModel}, then be sure 
that {@link #get(String)} is
+     * not overridden in custom subclasses; if it is, then those subclasses 
should be modernized to override
+     * {@link #get(String, boolean)} instead.
+     *
+     * @since 2.3.33
+     */
+    protected TemplateMethodModelEx getBeforeMethodCall(String key)
+            throws TemplateModelException, 
MethodCallAwareTemplateHashModel.ShouldNotBeGetAsMethodException {
+        TemplateModel result = get(key, true);
+        if (result instanceof  TemplateMethodModelEx) {
+            return (TemplateMethodModelEx) result;
+        }
+        if (result == null) {
+            return null;
+        }
+        throw new 
MethodCallAwareTemplateHashModel.ShouldNotBeGetAsMethodException(result, null);
+    }
+
+    /**
+     * Override this if you want to customize the behavior of {@link 
#get(String)}.
+     * In standard implementations at least, this is what {@link 
#get(String)}, and
+     * {@link MethodCallAwareTemplateHashModel#getBeforeMethodCall(String)} 
delegates to.
+     *
+     * @param key
+     *      Same as the parameter of {@link #get(String)}.
+     * @param beforeMethodCall
+     *      This is a hint that tells that the returned value will be called 
in the template. This was added to
+     *      implement {@link 
BeansWrapper.MethodAppearanceDecision#setMethodInsteadOfPropertyValueBeforeCall(boolean)}.
+     *      But in short, it's up to the model if itb will be lenient, and 
return a method, or fails with
+     *      {@link 
MethodCallAwareTemplateHashModel.ShouldNotBeGetAsMethodException}. In standard 
implementations at
+     *      least, this parameter is {@code false} when {@link #get(String)} 
is called, and
+     *      {@code true} when {@link 
MethodCallAwareTemplateHashModel#getBeforeMethodCall(String)} is called.
+     *
+     * @since 2.3.33
+     */
+    // Before calling this from FreeMarker classes, consider that some users 
may have overridden {@link #get(String)}
+    // instead, as this class didn't exist before 2.3.33. So with 
incompatibleImprovements before that, that should be
+    // the only place where this gets called, or else the behavior of the 
model will be inconsistent.
+    protected TemplateModel get(String key, boolean beforeMethodCall)
+        throws TemplateModelException, 
MethodCallAwareTemplateHashModel.ShouldNotBeGetAsMethodException {
         Class<?> clazz = object.getClass();
         Map<Object, Object> classInfo = 
wrapper.getClassIntrospector().get(clazz);
         TemplateModel retval = null;
@@ -148,7 +206,7 @@ implements TemplateHashModelEx, AdapterTemplateModel, 
WrapperTemplateModel, Temp
             if (wrapper.isMethodsShadowItems()) {
                 Object fd = classInfo.get(key);
                 if (fd != null) {
-                    retval = invokeThroughDescriptor(fd, classInfo);
+                    retval = invokeThroughDescriptor(fd, classInfo, 
beforeMethodCall);
                 } else {
                     retval = invokeGenericGet(classInfo, clazz, key);
                 }
@@ -160,7 +218,7 @@ implements TemplateHashModelEx, AdapterTemplateModel, 
WrapperTemplateModel, Temp
                 }
                 Object fd = classInfo.get(key);
                 if (fd != null) {
-                    retval = invokeThroughDescriptor(fd, classInfo);
+                    retval = invokeThroughDescriptor(fd, classInfo, 
beforeMethodCall);
                     if (retval == UNKNOWN && model == nullModel) {
                         // This is the (somewhat subtle) case where the 
generic get() returns null
                         // and we have no bean info, so we respect the fact 
that
@@ -181,6 +239,9 @@ implements TemplateHashModelEx, AdapterTemplateModel, 
WrapperTemplateModel, Temp
         } catch (TemplateModelException e) {
             throw e;
         } catch (Exception e) {
+            if (beforeMethodCall && e instanceof 
MethodCallAwareTemplateHashModel.ShouldNotBeGetAsMethodException) {
+                throw 
(MethodCallAwareTemplateHashModel.ShouldNotBeGetAsMethodException) e;
+            }
             throw new _TemplateModelException(e,
                     "An error has occurred when reading existing sub-variable 
", new _DelayedJQuote(key),
                     "; see cause exception! The type of the containing value 
was: ",
@@ -203,8 +264,9 @@ implements TemplateHashModelEx, AdapterTemplateModel, 
WrapperTemplateModel, Temp
         return 
wrapper.getClassIntrospector().get(object.getClass()).get(ClassIntrospector.GENERIC_GET_KEY)
 != null;
     }
     
-    private TemplateModel invokeThroughDescriptor(Object desc, Map<Object, 
Object> classInfo)
-            throws IllegalAccessException, InvocationTargetException, 
TemplateModelException {
+    private TemplateModel invokeThroughDescriptor(Object desc, Map<Object, 
Object> classInfo, boolean beforeMethodCall)
+            throws IllegalAccessException, InvocationTargetException, 
TemplateModelException,
+            MethodCallAwareTemplateHashModel.ShouldNotBeGetAsMethodException {
         // See if this particular instance has a cached implementation for the 
requested feature descriptor
         TemplateModel cachedModel;
         synchronized (this) {
@@ -229,8 +291,27 @@ implements TemplateHashModelEx, AdapterTemplateModel, 
WrapperTemplateModel, Temp
                                 ClassIntrospector.getArgTypes(classInfo, 
indexedReadMethod), wrapper);
                 }
             } else {
-                resultModel = wrapper.invokeMethod(object, pd.getReadMethod(), 
null);
-                // cachedModel remains null, as we don't cache these
+                if (!beforeMethodCall) {
+                    resultModel = wrapper.invokeMethod(object, 
pd.getReadMethod(), null);
+                    // cachedModel remains null, as we don't cache these
+                } else {
+                    if (pd.isMethodInsteadOfPropertyValueBeforeCall()) {
+                        Method method = pd.getReadMethod();
+                        resultModel = cachedModel = new SimpleMethodModel(
+                                object, method, 
CollectionUtils.EMPTY_CLASS_ARRAY, wrapper);
+                    } else {
+                        resultModel = wrapper.invokeMethod(object, 
pd.getReadMethod(), null);
+                        // cachedModel remains null, as we don't cache these
+
+                        if (!(resultModel instanceof TemplateMethodModelEx)) {
+                            throw new 
MethodCallAwareTemplateHashModel.ShouldNotBeGetAsMethodException(
+                                    resultModel,
+                                    "This member of the parent object is seen 
by templates as a property of it "
+                                            + "(with other words, an 
attribute, or a field), not a method of it. "
+                                            + "Thus, to get its value, it must 
not be called as a method.");
+                        }
+                    }
+                }
             }
         } else if (desc instanceof Field) {
             resultModel = wrapper.readField(object, (Field) desc);
diff --git 
a/freemarker-core/src/main/java/freemarker/ext/beans/BeansWrapper.java 
b/freemarker-core/src/main/java/freemarker/ext/beans/BeansWrapper.java
index be2455eb..df10ef46 100644
--- a/freemarker-core/src/main/java/freemarker/ext/beans/BeansWrapper.java
+++ b/freemarker-core/src/main/java/freemarker/ext/beans/BeansWrapper.java
@@ -19,6 +19,7 @@
 
 package freemarker.ext.beans;
 
+import java.beans.IntrospectionException;
 import java.beans.Introspector;
 import java.beans.PropertyDescriptor;
 import java.lang.reflect.AccessibleObject;
@@ -193,9 +194,11 @@ public class BeansWrapper implements RichObjectWrapper, 
WriteProtectable {
     private boolean simpleMapWrapper;  // initialized from the 
BeansWrapperConfiguration
     private boolean strict;  // initialized from the BeansWrapperConfiguration
     private boolean preferIndexedReadMethod; // initialized from the 
BeansWrapperConfiguration
-    
+    private final boolean useExModels;
+
     private final Version incompatibleImprovements;
-    
+
+
     /**
      * Creates a new instance with the incompatible-improvements-version 
specified in
      * {@link Configuration#DEFAULT_INCOMPATIBLE_IMPROVEMENTS}.
@@ -262,6 +265,19 @@ public class BeansWrapper implements RichObjectWrapper, 
WriteProtectable {
      *       The default of the {@link #setPreferIndexedReadMethod(boolean) 
preferIndexedReadMethod} setting changes
      *       from {@code true} to {@code false}.
      *     </li>  
+     *     <li>
+     *       <p>2.3.33 (or higher):
+     *       Java records 0-argument public methods with non-void return type 
are now exposed both as properties, and as
+     *       methods, while earlier they were only exposed as methods. That 
is, if in a record you have
+     *       {@code public String name()}, now in templates the value can be 
accessed both as {@code obj.name} (like a
+     *       property), and as {@code obj.name()} (for better backward 
compatibility only - it's bad style).
+     *
+     *       <p>On more technical level, {@link BeansWrapper} will create 
{@link StringModelEx} instead of
+     *       {@link StringModel}, etc. So some models will implement {@link 
MethodCallAwareTemplateHashModel} with that.
+     *       That also affects {@link DefaultObjectWrapper}, for the "generic" 
Java objects (that is, for classes that
+     *       are not {@link Number}-s, {@link List}-s, {@link Map}-s, etc., 
that has a specific wrapping in
+     *       {@link DefaultObjectWrapper}), and for the result of the {@code 
obj?api} (in case the object is a record).
+     *     </li>
      *   </ul>
      *   
      *   <p>Note that the version will be normalized to the lowest version 
where the same incompatible
@@ -353,6 +369,7 @@ public class BeansWrapper implements RichObjectWrapper, 
WriteProtectable {
         defaultDateType = bwConf.getDefaultDateType();
         outerIdentity = bwConf.getOuterIdentity() != null ? 
bwConf.getOuterIdentity() : this;
         strict = bwConf.isStrict();
+        useExModels = incompatibleImprovements.intValue() >= 
_VersionInts.V_2_3_33;
         
         if (!writeProtected) {
             // As this is not a read-only BeansWrapper, the classIntrospector 
will be possibly replaced for a few times,
@@ -633,7 +650,37 @@ public class BeansWrapper implements RichObjectWrapper, 
WriteProtectable {
             replaceClassIntrospector(builder);
         }
     }
-    
+
+    /**
+     * Sets the {@link ZeroArgumentNonVoidMethodPolicy} for classes that are 
not Java records.
+     *
+     * @since 2.3.33
+     */
+    public void 
setNonRecordZeroArgumentNonVoidMethodPolicy(ZeroArgumentNonVoidMethodPolicy 
nonRecordZeroArgumentNonVoidMethodPolicy) {
+        checkModifiable();
+
+        if (classIntrospector.getNonRecordZeroArgumentNonVoidMethodPolicy() != 
nonRecordZeroArgumentNonVoidMethodPolicy) {
+            ClassIntrospectorBuilder builder = 
classIntrospector.createBuilder();
+            
builder.setNonRecordZeroArgumentNonVoidMethodPolicy(nonRecordZeroArgumentNonVoidMethodPolicy);
+            replaceClassIntrospector(builder);
+        }
+    }
+
+    /**
+     * Sets the {@link ZeroArgumentNonVoidMethodPolicy} for classes that are 
not Java records.
+     *
+     * @since 2.3.33
+     */
+    public void 
setRecordZeroArgumentNonVoidMethodPolicy(ZeroArgumentNonVoidMethodPolicy 
recordZeroArgumentNonVoidMethodPolicy) {
+        checkModifiable();
+
+        if (classIntrospector.getRecordZeroArgumentNonVoidMethodPolicy() != 
recordZeroArgumentNonVoidMethodPolicy) {
+            ClassIntrospectorBuilder builder = 
classIntrospector.createBuilder();
+            
builder.setRecordZeroArgumentNonVoidMethodPolicy(recordZeroArgumentNonVoidMethodPolicy);
+            replaceClassIntrospector(builder);
+        }
+    }
+
     /**
      * Returns whether exposure of public instance fields of classes is 
      * enabled. See {@link #setExposeFields(boolean)} for details.
@@ -651,7 +698,25 @@ public class BeansWrapper implements RichObjectWrapper, 
WriteProtectable {
     public boolean getTreatDefaultMethodsAsBeanMembers() {
         return classIntrospector.getTreatDefaultMethodsAsBeanMembers();
     }
-    
+
+    /**
+     * See {@link 
#setNonRecordZeroArgumentNonVoidMethodPolicy(ZeroArgumentNonVoidMethodPolicy)}.
+     *
+     * @since 2.3.33
+     */
+    public ZeroArgumentNonVoidMethodPolicy 
getNonRecordZeroArgumentNonVoidMethodPolicy() {
+        return classIntrospector.getNonRecordZeroArgumentNonVoidMethodPolicy();
+    }
+
+    /**
+     * See {@link 
#setRecordZeroArgumentNonVoidMethodPolicy(ZeroArgumentNonVoidMethodPolicy)}.
+     *
+     * @since 2.3.33
+     */
+    public ZeroArgumentNonVoidMethodPolicy 
getRecordZeroArgumentNonVoidMethodPolicy() {
+        return classIntrospector.getRecordZeroArgumentNonVoidMethodPolicy();
+    }
+
     public MethodAppearanceFineTuner getMethodAppearanceFineTuner() {
         return classIntrospector.getMethodAppearanceFineTuner();
     }
@@ -865,7 +930,7 @@ public class BeansWrapper implements RichObjectWrapper, 
WriteProtectable {
     /**
      * Returns the version given with {@link #BeansWrapper(Version)}, 
normalized to the lowest version where a change
      * has occurred. Thus, this is not necessarily the same version than that 
was given to the constructor.
-     * 
+     *
      * @since 2.3.21
      */
     public Version getIncompatibleImprovements() {
@@ -894,7 +959,8 @@ public class BeansWrapper implements RichObjectWrapper, 
WriteProtectable {
      */
     protected static Version normalizeIncompatibleImprovementsVersion(Version 
incompatibleImprovements) {
         _TemplateAPI.checkVersionNotNullAndSupported(incompatibleImprovements);
-        return incompatibleImprovements.intValue() >= _VersionInts.V_2_3_27 ? 
Configuration.VERSION_2_3_27
+        return incompatibleImprovements.intValue() >= _VersionInts.V_2_3_33 ? 
Configuration.VERSION_2_3_33
+                : incompatibleImprovements.intValue() >= _VersionInts.V_2_3_27 
? Configuration.VERSION_2_3_27
                 : incompatibleImprovements.intValue() == _VersionInts.V_2_3_26 
? Configuration.VERSION_2_3_26
                 : is2324Bugfixed(incompatibleImprovements) ? 
Configuration.VERSION_2_3_24
                 : is2321Bugfixed(incompatibleImprovements) ? 
Configuration.VERSION_2_3_21
@@ -969,7 +1035,7 @@ public class BeansWrapper implements RichObjectWrapper, 
WriteProtectable {
      */
     @Override
     public TemplateHashModel wrapAsAPI(Object obj) throws 
TemplateModelException {
-        return new APIModel(obj, this);
+        return useExModels ? new APIModelEx(obj, this) : new APIModel(obj, 
this);
     }
 
     /**
@@ -1033,7 +1099,7 @@ public class BeansWrapper implements RichObjectWrapper, 
WriteProtectable {
         if (clazz.isArray()) {
             return ArrayModel.FACTORY;
         }
-        return StringModel.FACTORY;
+        return useExModels ? StringModelEx.FACTORY : StringModel.FACTORY;
     }
 
     /**
@@ -1855,15 +1921,32 @@ public class BeansWrapper implements RichObjectWrapper, 
WriteProtectable {
      */
     static public final class MethodAppearanceDecision {
         private PropertyDescriptor exposeAsProperty;
+        private boolean methodInsteadOfPropertyValueBeforeCall;
         private boolean replaceExistingProperty;
         private String exposeMethodAs;
         private boolean methodShadowsProperty;
-        
-        void setDefaults(Method m) {
-            exposeAsProperty = null;
+
+        /**
+         * @param appliedZeroArgumentNonVoidMethodPolicy
+         *      {@code null} if this is not a zero argument method with 
non-void return type.
+         */
+        void setDefaults(Method m, ZeroArgumentNonVoidMethodPolicy 
appliedZeroArgumentNonVoidMethodPolicy) {
+            if (appliedZeroArgumentNonVoidMethodPolicy != null && 
appliedZeroArgumentNonVoidMethodPolicy != 
ZeroArgumentNonVoidMethodPolicy.METHOD_ONLY) {
+                try {
+                    exposeAsProperty = new PropertyDescriptor(m.getName(), m, 
null);
+                } catch (IntrospectionException e) {
+                    throw new BugException("Failed to create 
PropertyDescriptor for " + m, e);
+                }
+                methodShadowsProperty = false;
+                methodInsteadOfPropertyValueBeforeCall = 
appliedZeroArgumentNonVoidMethodPolicy ==
+                        
ZeroArgumentNonVoidMethodPolicy.BOTH_PROPERTY_AND_METHOD;
+            } else {
+                exposeAsProperty = null;
+                methodInsteadOfPropertyValueBeforeCall = false;
+                methodShadowsProperty = true;
+            }
             replaceExistingProperty = false;
             exposeMethodAs = m.getName();
-            methodShadowsProperty = true;
         }
         
         /**
@@ -1935,6 +2018,23 @@ public class BeansWrapper implements RichObjectWrapper, 
WriteProtectable {
             this.methodShadowsProperty = shadowEarlierProperty;
         }
 
+        /**
+         * See in the documentation of {@link 
MethodAppearanceFineTuner#process}.
+         *
+         * @since 2.3.33
+         */
+        public boolean isMethodInsteadOfPropertyValueBeforeCall() {
+            return methodInsteadOfPropertyValueBeforeCall;
+        }
+
+        /**
+         * See in the documentation of {@link 
MethodAppearanceFineTuner#process}.
+         *
+         * @since 2.3.33
+         */
+        public void setMethodInsteadOfPropertyValueBeforeCall(boolean 
methodInsteadOfPropertyValueBeforeCall) {
+            this.methodInsteadOfPropertyValueBeforeCall = 
methodInsteadOfPropertyValueBeforeCall;
+        }
     }
     
     /**
diff --git 
a/freemarker-core/src/main/java/freemarker/ext/beans/BeansWrapperConfiguration.java
 
b/freemarker-core/src/main/java/freemarker/ext/beans/BeansWrapperConfiguration.java
index 5daaa909..22cc8d51 100644
--- 
a/freemarker-core/src/main/java/freemarker/ext/beans/BeansWrapperConfiguration.java
+++ 
b/freemarker-core/src/main/java/freemarker/ext/beans/BeansWrapperConfiguration.java
@@ -251,6 +251,36 @@ public abstract class BeansWrapperConfiguration implements 
Cloneable {
         
classIntrospectorBuilder.setTreatDefaultMethodsAsBeanMembers(treatDefaultMethodsAsBeanMembers);
     }
 
+    /**
+     * @since 2.3.33
+     */
+    public ZeroArgumentNonVoidMethodPolicy 
getNonRecordZeroArgumentNonVoidMethodPolicy() {
+        return 
classIntrospectorBuilder.getNonRecordZeroArgumentNonVoidMethodPolicy();
+    }
+
+    /**
+     * See {@link 
BeansWrapper#setNonRecordZeroArgumentNonVoidMethodPolicy(ZeroArgumentNonVoidMethodPolicy)}
+     * @since 2.3.33
+     */
+    public void 
setNonRecordZeroArgumentNonVoidMethodPolicy(ZeroArgumentNonVoidMethodPolicy 
nonRecordZeroArgumentNonVoidMethodPolicy) {
+        
classIntrospectorBuilder.setNonRecordZeroArgumentNonVoidMethodPolicy(nonRecordZeroArgumentNonVoidMethodPolicy);
+    }
+
+    /**
+     * @since 2.3.33
+     */
+    public ZeroArgumentNonVoidMethodPolicy 
getRecordZeroArgumentNonVoidMethodPolicy() {
+        return 
classIntrospectorBuilder.getRecordZeroArgumentNonVoidMethodPolicy();
+    }
+
+    /**
+     * See {@link 
BeansWrapper#setRecordZeroArgumentNonVoidMethodPolicy(ZeroArgumentNonVoidMethodPolicy)}
+     * @since 2.3.33
+     */
+    public void 
setRecordZeroArgumentNonVoidMethodPolicy(ZeroArgumentNonVoidMethodPolicy 
recordZeroArgumentNonVoidMethodPolicy) {
+        
classIntrospectorBuilder.setRecordZeroArgumentNonVoidMethodPolicy(recordZeroArgumentNonVoidMethodPolicy);
+    }
+
     public MethodAppearanceFineTuner getMethodAppearanceFineTuner() {
         return classIntrospectorBuilder.getMethodAppearanceFineTuner();
     }
diff --git 
a/freemarker-core/src/main/java/freemarker/ext/beans/ClassIntrospector.java 
b/freemarker-core/src/main/java/freemarker/ext/beans/ClassIntrospector.java
index bf867814..8f95e182 100644
--- a/freemarker-core/src/main/java/freemarker/ext/beans/ClassIntrospector.java
+++ b/freemarker-core/src/main/java/freemarker/ext/beans/ClassIntrospector.java
@@ -48,11 +48,13 @@ import java.util.Set;
 import java.util.concurrent.ConcurrentHashMap;
 
 import freemarker.core.BugException;
+import freemarker.core._JavaVersions;
 import freemarker.ext.beans.BeansWrapper.MethodAppearanceDecision;
 import freemarker.ext.beans.BeansWrapper.MethodAppearanceDecisionInput;
 import freemarker.ext.util.ModelCache;
 import freemarker.log.Logger;
 import freemarker.template.Version;
+import freemarker.template.utility.CollectionUtils;
 import freemarker.template.utility.NullArgumentException;
 import freemarker.template.utility.SecurityUtilities;
 
@@ -82,7 +84,7 @@ class ClassIntrospector {
     private static final ExecutableMemberSignature GET_OBJECT_SIGNATURE =
             new ExecutableMemberSignature("get", new Class[] { Object.class });
     private static final ExecutableMemberSignature TO_STRING_SIGNATURE =
-            new ExecutableMemberSignature("toString", new Class[0]);
+            new ExecutableMemberSignature("toString", 
CollectionUtils.EMPTY_CLASS_ARRAY);
 
     /**
      * When this property is true, some things are stricter. This is mostly to 
catch suspicious things in development
@@ -151,6 +153,9 @@ class ClassIntrospector {
     final MethodAppearanceFineTuner methodAppearanceFineTuner;
     final MethodSorter methodSorter;
     final boolean treatDefaultMethodsAsBeanMembers;
+    final ZeroArgumentNonVoidMethodPolicy 
nonRecordZeroArgumentNonVoidMethodPolicy;
+    final ZeroArgumentNonVoidMethodPolicy 
recordZeroArgumentNonVoidMethodPolicy;
+    final private boolean recordAware;
     final Version incompatibleImprovements;
 
     /** See {@link #getHasSharedInstanceRestrictions()} */
@@ -192,6 +197,14 @@ class ClassIntrospector {
         this.methodAppearanceFineTuner = 
builder.getMethodAppearanceFineTuner();
         this.methodSorter = builder.getMethodSorter();
         this.treatDefaultMethodsAsBeanMembers = 
builder.getTreatDefaultMethodsAsBeanMembers();
+        this.nonRecordZeroArgumentNonVoidMethodPolicy = 
builder.getNonRecordZeroArgumentNonVoidMethodPolicy();
+        this.recordZeroArgumentNonVoidMethodPolicy = 
builder.getRecordZeroArgumentNonVoidMethodPolicy();
+        this.recordAware = nonRecordZeroArgumentNonVoidMethodPolicy != 
recordZeroArgumentNonVoidMethodPolicy;
+        if (recordAware && _JavaVersions.JAVA_16 == null) {
+            throw new IllegalArgumentException(
+                    "nonRecordZeroArgumentNonVoidMethodPolicy != 
recordZeroArgumentNonVoidMethodPolicy, " +
+                    "but Java 16 support is not available.");
+        }
         this.incompatibleImprovements = builder.getIncompatibleImprovements();
 
         this.sharedLock = sharedLock;
@@ -329,13 +342,26 @@ class ClassIntrospector {
             Map<ExecutableMemberSignature, List<Method>> accessibleMethods,
             ClassMemberAccessPolicy effClassMemberAccessPolicy) throws 
IntrospectionException {
         BeanInfo beanInfo = Introspector.getBeanInfo(clazz);
+
+        boolean treatClassAsRecord = recordAware && 
_JavaVersions.JAVA_16.isRecord(clazz);
+        ZeroArgumentNonVoidMethodPolicy zeroArgumentNonVoidMethodPolicy = 
treatClassAsRecord
+                ? recordZeroArgumentNonVoidMethodPolicy
+                : nonRecordZeroArgumentNonVoidMethodPolicy;
+
+        // For real Java Beans properties only, used to exclude them from 
creating fake properties based on ZeroArgumentNonVoidMethod.
+        Set<String> beanPropertyReadMethodNameCollector = 
zeroArgumentNonVoidMethodPolicy != ZeroArgumentNonVoidMethodPolicy.METHOD_ONLY
+                ? new HashSet<String>()
+                : null;
+
         List<PropertyDescriptor> pdas = getPropertyDescriptors(beanInfo, 
clazz);
         int pdasLength = pdas.size();
         // Reverse order shouldn't mater, but we keep it to not risk backward 
incompatibility.
         for (int i = pdasLength - 1; i >= 0; --i) {
             addPropertyDescriptorToClassIntrospectionData(
-                    introspData, pdas.get(i),
-                    accessibleMethods, effClassMemberAccessPolicy);
+                    introspData, pdas.get(i), false,
+                    accessibleMethods,
+                    beanPropertyReadMethodNameCollector,
+                    effClassMemberAccessPolicy);
         }
 
         if (exposureLevel < BeansWrapper.EXPOSE_PROPERTIES_ONLY) {
@@ -348,7 +374,11 @@ class ClassIntrospector {
             for (int i = mdsSize - 1; i >= 0; --i) {
                 final Method method = 
getMatchingAccessibleMethod(mds.get(i).getMethod(), accessibleMethods);
                 if (method != null && 
effClassMemberAccessPolicy.isMethodExposed(method)) {
-                    decision.setDefaults(method);
+                    ZeroArgumentNonVoidMethodPolicy 
appliedZeroArgumentNonVoidMethodPolicy =
+                            getAppliedZeroArgumentNonVoidMethodPolicy(
+                                    method, 
beanPropertyReadMethodNameCollector, zeroArgumentNonVoidMethodPolicy);
+
+                    decision.setDefaults(method, 
appliedZeroArgumentNonVoidMethodPolicy);
                     if (methodAppearanceFineTuner != null) {
                         if (decisionInput == null) {
                             decisionInput = new 
MethodAppearanceDecisionInput();
@@ -364,7 +394,8 @@ class ClassIntrospector {
                             (decision.getReplaceExistingProperty()
                                     || !(introspData.get(propDesc.getName()) 
instanceof FastPropertyDescriptor))) {
                         addPropertyDescriptorToClassIntrospectionData(
-                                introspData, propDesc, accessibleMethods, 
effClassMemberAccessPolicy);
+                                introspData, propDesc, 
decision.isMethodInsteadOfPropertyValueBeforeCall(),
+                                accessibleMethods, null, 
effClassMemberAccessPolicy);
                     }
 
                     String methodKey = decision.getExposeMethodAs();
@@ -404,6 +435,18 @@ class ClassIntrospector {
         } // end if (exposureLevel < EXPOSE_PROPERTIES_ONLY)
     }
 
+    private static ZeroArgumentNonVoidMethodPolicy 
getAppliedZeroArgumentNonVoidMethodPolicy(Method method, Set<String> 
beanPropertyReadMethodNameCollector, ZeroArgumentNonVoidMethodPolicy 
zeroArgumentNonVoidMethodPolicy) {
+        if (method.getParameterCount() == 0 && method.getReturnType() != 
void.class) {
+            if (beanPropertyReadMethodNameCollector != null && 
beanPropertyReadMethodNameCollector.contains(method.getName())) {
+                return ZeroArgumentNonVoidMethodPolicy.METHOD_ONLY;
+            } else {
+                return zeroArgumentNonVoidMethodPolicy;
+            }
+        } else {
+            return null;
+        }
+    }
+
     /**
      * Very similar to {@link BeanInfo#getPropertyDescriptors()}, but can deal 
with Java 8 default methods too.
      */
@@ -673,8 +716,9 @@ class ClassIntrospector {
     }
 
     private void addPropertyDescriptorToClassIntrospectionData(Map<Object, 
Object> introspData,
-            PropertyDescriptor pd,
+            PropertyDescriptor pd, boolean 
methodInsteadOfPropertyValueBeforeCall,
             Map<ExecutableMemberSignature, List<Method>> accessibleMethods,
+            Set<String> beanPropertyReadMethodNameCollector,
             ClassMemberAccessPolicy effClassMemberAccessPolicy) {
         Method readMethod = getMatchingAccessibleMethod(pd.getReadMethod(), 
accessibleMethods);
         if (readMethod != null && 
!effClassMemberAccessPolicy.isMethodExposed(readMethod)) {
@@ -697,7 +741,13 @@ class ClassIntrospector {
         }
         
         if (readMethod != null || indexedReadMethod != null) {
-            introspData.put(pd.getName(), new 
FastPropertyDescriptor(readMethod, indexedReadMethod));
+            introspData.put(pd.getName(), new FastPropertyDescriptor(
+                    readMethod, indexedReadMethod,
+                    methodInsteadOfPropertyValueBeforeCall));
+        }
+
+        if (readMethod != null && beanPropertyReadMethodNameCollector != null) 
{
+            beanPropertyReadMethodNameCollector.add(readMethod.getName());
         }
     }
 
@@ -1076,6 +1126,14 @@ class ClassIntrospector {
         return treatDefaultMethodsAsBeanMembers;
     }
 
+    ZeroArgumentNonVoidMethodPolicy 
getNonRecordZeroArgumentNonVoidMethodPolicy() {
+        return nonRecordZeroArgumentNonVoidMethodPolicy;
+    }
+
+    ZeroArgumentNonVoidMethodPolicy getRecordZeroArgumentNonVoidMethodPolicy() 
{
+        return recordZeroArgumentNonVoidMethodPolicy;
+    }
+
     MethodAppearanceFineTuner getMethodAppearanceFineTuner() {
         return methodAppearanceFineTuner;
     }
diff --git 
a/freemarker-core/src/main/java/freemarker/ext/beans/ClassIntrospectorBuilder.java
 
b/freemarker-core/src/main/java/freemarker/ext/beans/ClassIntrospectorBuilder.java
index 76e42318..24ad2735 100644
--- 
a/freemarker-core/src/main/java/freemarker/ext/beans/ClassIntrospectorBuilder.java
+++ 
b/freemarker-core/src/main/java/freemarker/ext/beans/ClassIntrospectorBuilder.java
@@ -26,6 +26,7 @@ import java.util.HashMap;
 import java.util.Iterator;
 import java.util.Map;
 
+import freemarker.core._JavaVersions;
 import freemarker.template.Configuration;
 import freemarker.template.Version;
 import freemarker.template._TemplateAPI;
@@ -46,6 +47,8 @@ final class ClassIntrospectorBuilder implements Cloneable {
     private boolean exposeFields;
     private MemberAccessPolicy memberAccessPolicy;
     private boolean treatDefaultMethodsAsBeanMembers;
+    private ZeroArgumentNonVoidMethodPolicy 
nonRecordZeroArgumentNonVoidMethodPolicy;
+    private ZeroArgumentNonVoidMethodPolicy 
recordZeroArgumentNonVoidMethodPolicy;
     private MethodAppearanceFineTuner methodAppearanceFineTuner;
     private MethodSorter methodSorter;
     // Attention:
@@ -60,6 +63,8 @@ final class ClassIntrospectorBuilder implements Cloneable {
         exposeFields = ci.exposeFields;
         memberAccessPolicy = ci.memberAccessPolicy;
         treatDefaultMethodsAsBeanMembers = ci.treatDefaultMethodsAsBeanMembers;
+        nonRecordZeroArgumentNonVoidMethodPolicy = 
ci.nonRecordZeroArgumentNonVoidMethodPolicy;
+        recordZeroArgumentNonVoidMethodPolicy = 
ci.recordZeroArgumentNonVoidMethodPolicy;
         methodAppearanceFineTuner = ci.methodAppearanceFineTuner;
         methodSorter = ci.methodSorter;
     }
@@ -69,15 +74,18 @@ final class ClassIntrospectorBuilder implements Cloneable {
         // change in the BeansWrapper.normalizeIncompatibleImprovements 
results. That is, this class may don't react
         // to some version changes that affects BeansWrapper, but not the 
other way around.
         this.incompatibleImprovements = 
normalizeIncompatibleImprovementsVersion(incompatibleImprovements);
-        treatDefaultMethodsAsBeanMembers
-                = incompatibleImprovements.intValue() >= _VersionInts.V_2_3_26;
+        treatDefaultMethodsAsBeanMembers = incompatibleImprovements.intValue() 
>= _VersionInts.V_2_3_26;
+        nonRecordZeroArgumentNonVoidMethodPolicy = 
ZeroArgumentNonVoidMethodPolicy.METHOD_ONLY;
+        recordZeroArgumentNonVoidMethodPolicy = 
incompatibleImprovements.intValue() >= _VersionInts.V_2_3_33 && 
_JavaVersions.JAVA_16 != null
+                ? ZeroArgumentNonVoidMethodPolicy.BOTH_PROPERTY_AND_METHOD : 
ZeroArgumentNonVoidMethodPolicy.METHOD_ONLY;
         memberAccessPolicy = 
DefaultMemberAccessPolicy.getInstance(this.incompatibleImprovements);
     }
 
     private static Version normalizeIncompatibleImprovementsVersion(Version 
incompatibleImprovements) {
         _TemplateAPI.checkVersionNotNullAndSupported(incompatibleImprovements);
         // All breakpoints here must occur in 
BeansWrapper.normalizeIncompatibleImprovements!
-        return incompatibleImprovements.intValue() >= _VersionInts.V_2_3_30 ? 
Configuration.VERSION_2_3_30
+        return incompatibleImprovements.intValue() >= _VersionInts.V_2_3_33 ? 
Configuration.VERSION_2_3_33
+                : incompatibleImprovements.intValue() >= _VersionInts.V_2_3_30 
? Configuration.VERSION_2_3_30
                 : incompatibleImprovements.intValue() >= _VersionInts.V_2_3_21 
? Configuration.VERSION_2_3_21
                 : Configuration.VERSION_2_3_0;
     }
@@ -98,6 +106,8 @@ final class ClassIntrospectorBuilder implements Cloneable {
         result = prime * result + incompatibleImprovements.hashCode();
         result = prime * result + (exposeFields ? 1231 : 1237);
         result = prime * result + (treatDefaultMethodsAsBeanMembers ? 1231 : 
1237);
+        result = prime * result + 
nonRecordZeroArgumentNonVoidMethodPolicy.hashCode();
+        result = prime * result + 
recordZeroArgumentNonVoidMethodPolicy.hashCode();
         result = prime * result + exposureLevel;
         result = prime * result + memberAccessPolicy.hashCode();
         result = prime * result + 
System.identityHashCode(methodAppearanceFineTuner);
@@ -115,6 +125,8 @@ final class ClassIntrospectorBuilder implements Cloneable {
         if (!incompatibleImprovements.equals(other.incompatibleImprovements)) 
return false;
         if (exposeFields != other.exposeFields) return false;
         if (treatDefaultMethodsAsBeanMembers != 
other.treatDefaultMethodsAsBeanMembers) return false;
+        if (nonRecordZeroArgumentNonVoidMethodPolicy != 
other.nonRecordZeroArgumentNonVoidMethodPolicy) return false;
+        if (recordZeroArgumentNonVoidMethodPolicy != 
other.recordZeroArgumentNonVoidMethodPolicy) return false;
         if (exposureLevel != other.exposureLevel) return false;
         if (!memberAccessPolicy.equals(other.memberAccessPolicy)) return false;
         if (methodAppearanceFineTuner != other.methodAppearanceFineTuner) 
return false;
@@ -153,6 +165,36 @@ final class ClassIntrospectorBuilder implements Cloneable {
         this.treatDefaultMethodsAsBeanMembers = 
treatDefaultMethodsAsBeanMembers;
     }
 
+    /**
+     * @since 2.3.33
+     */
+    public ZeroArgumentNonVoidMethodPolicy 
getNonRecordZeroArgumentNonVoidMethodPolicy() {
+        return nonRecordZeroArgumentNonVoidMethodPolicy;
+    }
+
+    /**
+     * @since 2.3.33
+     */
+    public void 
setNonRecordZeroArgumentNonVoidMethodPolicy(ZeroArgumentNonVoidMethodPolicy 
nonRecordZeroArgumentNonVoidMethodPolicy) {
+        NullArgumentException.check(nonRecordZeroArgumentNonVoidMethodPolicy);
+        this.nonRecordZeroArgumentNonVoidMethodPolicy = 
nonRecordZeroArgumentNonVoidMethodPolicy;
+    }
+
+    /**
+     * @since 2.3.33
+     */
+    public ZeroArgumentNonVoidMethodPolicy 
getRecordZeroArgumentNonVoidMethodPolicy() {
+        return recordZeroArgumentNonVoidMethodPolicy;
+    }
+
+    /**
+     * @since 2.3.33
+     */
+    public void 
setRecordZeroArgumentNonVoidMethodPolicy(ZeroArgumentNonVoidMethodPolicy 
recordZeroArgumentNonVoidMethodPolicy) {
+        NullArgumentException.check(recordZeroArgumentNonVoidMethodPolicy);
+        this.recordZeroArgumentNonVoidMethodPolicy = 
recordZeroArgumentNonVoidMethodPolicy;
+    }
+
     public MemberAccessPolicy getMemberAccessPolicy() {
         return memberAccessPolicy;
     }
diff --git 
a/freemarker-core/src/main/java/freemarker/ext/beans/FastPropertyDescriptor.java
 
b/freemarker-core/src/main/java/freemarker/ext/beans/FastPropertyDescriptor.java
index 12d43de1..7ae8b674 100644
--- 
a/freemarker-core/src/main/java/freemarker/ext/beans/FastPropertyDescriptor.java
+++ 
b/freemarker-core/src/main/java/freemarker/ext/beans/FastPropertyDescriptor.java
@@ -29,10 +29,13 @@ import java.lang.reflect.Method;
 final class FastPropertyDescriptor {
     private final Method readMethod;
     private final Method indexedReadMethod;
-    
-    public FastPropertyDescriptor(Method readMethod, Method indexedReadMethod) 
{
+    private final boolean methodInsteadOfPropertyValueBeforeCall;
+
+    public FastPropertyDescriptor(
+            Method readMethod, Method indexedReadMethod, boolean 
methodInsteadOfPropertyValueBeforeCall) {
         this.readMethod = readMethod;
         this.indexedReadMethod = indexedReadMethod;
+        this.methodInsteadOfPropertyValueBeforeCall = 
methodInsteadOfPropertyValueBeforeCall;
     }
 
     public Method getReadMethod() {
@@ -42,5 +45,14 @@ final class FastPropertyDescriptor {
     public Method getIndexedReadMethod() {
         return indexedReadMethod;
     }
-    
+
+    /**
+     * If this is true, and the property value is referred directly before 
it's called in a template, then
+     * the instead of the property value, it the value should be the read 
method (which therefore will be called).
+     *
+     * @since 2.3.33
+     */
+    public boolean isMethodInsteadOfPropertyValueBeforeCall() {
+        return methodInsteadOfPropertyValueBeforeCall;
+    }
 }
diff --git 
a/freemarker-core/src/main/java/freemarker/ext/beans/MethodAppearanceFineTuner.java
 
b/freemarker-core/src/main/java/freemarker/ext/beans/MethodAppearanceFineTuner.java
index 98c6416c..a514d8cf 100644
--- 
a/freemarker-core/src/main/java/freemarker/ext/beans/MethodAppearanceFineTuner.java
+++ 
b/freemarker-core/src/main/java/freemarker/ext/beans/MethodAppearanceFineTuner.java
@@ -24,6 +24,8 @@ import java.beans.PropertyDescriptor;
 
 import freemarker.ext.beans.BeansWrapper.MethodAppearanceDecision;
 import freemarker.ext.beans.BeansWrapper.MethodAppearanceDecisionInput;
+import freemarker.template.DefaultObjectWrapper;
+import freemarker.template.Version;
 
 /**
  * Used for customizing how the methods are visible from templates, via
@@ -76,6 +78,30 @@ public interface MethodAppearanceFineTuner {
      *     of the same name was already assigned earlier, it won't be
      *     replaced by the new one by default, however this can be changed with
      *     {@link 
MethodAppearanceDecision#setReplaceExistingProperty(boolean)}.
+     *   <li>If something is exposed as property via
+     *     {@link 
MethodAppearanceDecision#setExposeAsProperty(PropertyDescriptor)} (and not only 
because it's a real
+     *     JavaBeans property), and you also want the property value to be 
accessible in templates as the return value
+     *     of a 0-argument method of the same name, then call
+     *     {@link 
MethodAppearanceDecision#setMethodInsteadOfPropertyValueBeforeCall(boolean)} 
with {@code true}.
+     *     Here's an example to explain that. Let's say, you have a class that 
contains "public String name()", and you
+     *     exposed that as a property via {@link 
MethodAppearanceDecision#setExposeAsProperty(PropertyDescriptor)}. So
+     *     far, you can access the property value from templates as {@code 
user.name}, but {@code user.name()} will
+     *     fail, saying that you try to call a {@code String} (because you 
apply the {@code ()} operator on the result
+     *     of {@code user.name}). But with
+     *     {@link 
MethodAppearanceDecision#setMethodInsteadOfPropertyValueBeforeCall(boolean)} 
{@code true},
+     *     both {@code user.name}, and {@code user.name()} will do the same. 
This is a parse-time
+     *     trick that only works when the result of the dot operator is called 
immediately (and therefore the dot
+     *     operator knows that you will call the result of it). The practical 
reason for this feature is that the
+     *     convention of having {@code SomeType something()} instead of {@code 
SomeType getSomething()} spreads, and
+     *     thus we can't tell anymore if {@code SomeType something()} just 
reads a value, and hence should be accessed
+     *     like {@code obj.something}, or it's more like an operation that has 
side effect, and therefore should be
+     *     accessed like {@code obj.something()}. So with being lenient, the 
template author is free to decide which is
+     *     the more fitting. Also, for accessing Java records components, the 
proper way is {@code obj.something}, but
+     *     before FreeMarker was aware of records (and hence that those 
methods are like property read methods), the
+     *     only way that worked was {@code obj.something()}, so to be more 
backward compatible, we have to support both.
+     *     Currently the default of this is {@code false}, except if {@link 
BeansWrapper#getIncompatibleImprovements()}
+     *     (or {@link DefaultObjectWrapper#getIncompatibleImprovements()}, as 
that's in a subclass of
+     *     {@link BeansWrapper}) is at least 2.3.33, but see details at {@link 
BeansWrapper#BeansWrapper(Version)}.
      *   <li>Prevent the method to hide a JavaBeans property (fake or real) of
      *     the same name by calling
      *     {@link MethodAppearanceDecision#setMethodShadowsProperty(boolean)}
diff --git 
a/freemarker-core/src/main/java/freemarker/ext/beans/MethodCallAwareTemplateHashModel.java
 
b/freemarker-core/src/main/java/freemarker/ext/beans/MethodCallAwareTemplateHashModel.java
new file mode 100644
index 00000000..45f08852
--- /dev/null
+++ 
b/freemarker-core/src/main/java/freemarker/ext/beans/MethodCallAwareTemplateHashModel.java
@@ -0,0 +1,115 @@
+/*
+ * 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
+ *
+ *   http://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 freemarker.ext.beans;
+
+import freemarker.core.NonMethodException;
+import freemarker.template.ObjectWrapper;
+import freemarker.template.TemplateHashModel;
+import freemarker.template.TemplateMethodModelEx;
+import freemarker.template.TemplateModel;
+import freemarker.template.TemplateModelException;
+import freemarker.template.utility.NullArgumentException;
+
+/**
+ * Adds a getter method that can return different result than {@link 
#get(String)}, knowing that the result of it will
+ * be called as a method. At least as of 2.3.33, this is only utilized by the 
template language for 0-argument method
+ * calls directly after the dot operator and the key. For example, if in the 
template you have
+ * {@code someRecord.someComponent()}, and there {@code someRecord} was 
wrapped by the {@link ObjectWrapper} into
+ * a {@link TemplateHashModel} that also implements this interface, then the 
dot operator will call
+ * {@link #getBeforeMethodCall(String) getBeforeMethodCall("someComponent")}, 
rather than
+ * {@link #get(String) get("someComponent")}. This is needed to implement the
+ * {@link 
BeansWrapper.MethodAppearanceDecision#setMethodInsteadOfPropertyValueBeforeCall(boolean)}
 feature; please see
+ * more there.
+ *
+ * <p>While technically we could do the same for method calls with more the 0 
arguments, as of 2.3.33 at least we
+ * don't want to generalize this to that case. The FreeMarker 2.x template 
language doesn't have separated namespace for
+ * methods, so this is already a hack as is, but we had to address the issue 
with Java records (again, see that
+ * at {@link 
BeansWrapper.MethodAppearanceDecision#setMethodInsteadOfPropertyValueBeforeCall(boolean)}).
+ *
+ * @since 2.3.33
+ */
+public interface MethodCallAwareTemplateHashModel extends TemplateHashModel {
+
+    /**
+     * This is called instead of {@link #get(String)}, if we know that the 
return value must be a method.
+     * The advantage of this is that we can coerce the value to a method when 
desirable, and otherwise can give a more
+     * specific error message in the resulting exception than the standard 
{@link NonMethodException} would.
+     *
+     * @param key
+     *      Same as for {@link #get(String)}
+     *
+     * @return
+     *      Same as for just like {@link #get(String)}, except this must be a 
{@link TemplateMethodModelEx} (unless it's
+     *      {@code null}).
+     *
+     * @throws ShouldNotBeGetAsMethodException
+     *      If the value for the given key exists, but it shouldn't be coerced 
the method. This will be converted to
+     *      {@link NonMethodException} by the engine, but in this exception 
you can optionally give a more specific
+     *      explanation, and that will be added to the resulting {@link 
NonMethodException} as a hint to the user.
+     */
+    TemplateMethodModelEx getBeforeMethodCall(String key)
+            throws TemplateModelException, ShouldNotBeGetAsMethodException;
+
+    /**
+     * Thrown by {@link #getBeforeMethodCall(String)}; see there.
+     */
+    final class ShouldNotBeGetAsMethodException extends Exception {
+        private final TemplateModel actualValue;
+        private final String hint;
+
+        /**
+         * Same as {@link ShouldNotBeGetAsMethodException(TemplateModel, 
String, Throwable)}, with {@code null}
+         * cause exception argument.
+         */
+        public ShouldNotBeGetAsMethodException(TemplateModel actualValue, 
String hint) {
+            this(actualValue, hint, null);
+        }
+
+        /**
+         * @param actualValue
+         *      The actual value we got instead of a method; can't be {@code 
null}!
+         * @param hint
+         *      Hint for the user, that's added to the error message; {@code 
null} if you just want the plain
+         *      {@link NonMethodException} error message.
+         * @param cause
+         *      Can be {@code null}.
+         */
+        public ShouldNotBeGetAsMethodException(TemplateModel actualValue, 
String hint, Throwable cause) {
+            super(null, cause, true, false);
+            NullArgumentException.check(actualValue);
+            this.actualValue = actualValue;
+            this.hint = hint;
+        }
+
+        /**
+         * The actual value we got instead of a method; not {@code null}.
+         */
+        public TemplateModel getActualValue() {
+            return actualValue;
+        }
+
+        /**
+         * Additional hint for the user; maybe {@code null}.
+         */
+        public String getHint() {
+            return hint;
+        }
+    }
+}
diff --git 
a/freemarker-core/src/main/java/freemarker/ext/beans/StringModel.java 
b/freemarker-core/src/main/java/freemarker/ext/beans/StringModel.java
index b53872d0..399a1ff1 100644
--- a/freemarker-core/src/main/java/freemarker/ext/beans/StringModel.java
+++ b/freemarker-core/src/main/java/freemarker/ext/beans/StringModel.java
@@ -20,8 +20,6 @@
 package freemarker.ext.beans;
 
 import freemarker.ext.util.ModelFactory;
-import freemarker.template.ObjectWrapper;
-import freemarker.template.TemplateModel;
 import freemarker.template.TemplateScalarModel;
 
 /**
@@ -31,14 +29,7 @@ import freemarker.template.TemplateScalarModel;
  */
 public class StringModel extends BeanModel
 implements TemplateScalarModel {
-    static final ModelFactory FACTORY =
-        new ModelFactory()
-        {
-            @Override
-            public TemplateModel create(Object object, ObjectWrapper wrapper) {
-                return new StringModel(object, (BeansWrapper) wrapper);
-            }
-        };
+    static final ModelFactory FACTORY = (object, wrapper) -> new 
StringModel(object, (BeansWrapper) wrapper);
 
     // Package visible for testing
     static final String TO_STRING_NOT_EXPOSED = "[toString not exposed]";
diff --git 
a/freemarker-core/src/main/java/freemarker/ext/beans/StringModelEx.java 
b/freemarker-core/src/main/java/freemarker/ext/beans/StringModelEx.java
new file mode 100644
index 00000000..e4d91f8f
--- /dev/null
+++ b/freemarker-core/src/main/java/freemarker/ext/beans/StringModelEx.java
@@ -0,0 +1,53 @@
+/*
+ * 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
+ *
+ *   http://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 freemarker.ext.beans;
+
+import freemarker.ext.util.ModelFactory;
+import freemarker.template.TemplateMethodModelEx;
+import freemarker.template.TemplateModelException;
+
+/**
+ * Like {@link StringModel}, but adds support for {@link 
MethodCallAwareTemplateHashModel}.
+ *
+ * @since 2.3.33
+ */
+public class StringModelEx extends StringModel implements 
MethodCallAwareTemplateHashModel {
+    static final ModelFactory FACTORY = (object, wrapper) -> new 
StringModelEx(object, (BeansWrapper) wrapper);
+
+    /**
+     * Creates a new model that wraps the specified object with BeanModel + 
scalar functionality.
+     *
+     * @param object
+     *         the object to wrap into a model.
+     * @param wrapper
+     *         the {@link BeansWrapper} associated with this model. Every 
model has to have an associated
+     *         {@link BeansWrapper} instance. The model gains many attributes 
from its wrapper, including the caching
+     *         behavior, method exposure level, method-over-item shadowing 
policy etc.
+     */
+    public StringModelEx(Object object, BeansWrapper wrapper) {
+        super(object, wrapper);
+    }
+
+    @Override
+    public TemplateMethodModelEx getBeforeMethodCall(String key) throws 
TemplateModelException,
+            ShouldNotBeGetAsMethodException {
+        return super.getBeforeMethodCall(key);
+    }
+}
diff --git a/freemarker-core/src/main/java/freemarker/ext/beans/APIModel.java 
b/freemarker-core/src/main/java/freemarker/ext/beans/ZeroArgumentNonVoidMethodPolicy.java
similarity index 61%
copy from freemarker-core/src/main/java/freemarker/ext/beans/APIModel.java
copy to 
freemarker-core/src/main/java/freemarker/ext/beans/ZeroArgumentNonVoidMethodPolicy.java
index 4580ac8d..5c5bd75b 100644
--- a/freemarker-core/src/main/java/freemarker/ext/beans/APIModel.java
+++ 
b/freemarker-core/src/main/java/freemarker/ext/beans/ZeroArgumentNonVoidMethodPolicy.java
@@ -20,26 +20,26 @@
 package freemarker.ext.beans;
 
 /**
- * Exposes the Java API (and properties) of an object.
- * 
- * <p>
- * Notes:
- * <ul>
- * <li>The exposing level is inherited from the {@link BeansWrapper}</li>
- * <li>But methods will always shadow properties and fields with identical 
name, regardless of {@link BeansWrapper}
- * settings</li>
- * </ul>
- * 
- * @since 2.3.22
+ * How to show 0 argument non-void public methods to templates.
+ *
+ * @since 2.3.33
  */
-final class APIModel extends BeanModel {
+public enum ZeroArgumentNonVoidMethodPolicy {
+
+    /**
+     * {@code obj.m}, or {@code obj.m()}, both do the same.
+     * This can work because of {@link MethodCallAwareTemplateHashModel}.
+     * See also {@link 
BeansWrapper.MethodAppearanceDecision#setMethodInsteadOfPropertyValueBeforeCall(boolean)}.
+     */
+    BOTH_PROPERTY_AND_METHOD,
 
-    APIModel(Object object, BeansWrapper wrapper) {
-        super(object, wrapper, false);
-    }
+    /**
+     * {@code obj.m()}
+     */
+    METHOD_ONLY,
 
-    protected boolean isMethodsShadowItems() {
-        return true;
-    }
-    
+    /**
+     * {@code obj.m}
+     */
+    PROPERTY_ONLY
 }
diff --git a/freemarker-core/src/main/java/freemarker/ext/beans/_BeansAPI.java 
b/freemarker-core/src/main/java/freemarker/ext/beans/_BeansAPI.java
index 64d9797e..a8a6c775 100644
--- a/freemarker-core/src/main/java/freemarker/ext/beans/_BeansAPI.java
+++ b/freemarker-core/src/main/java/freemarker/ext/beans/_BeansAPI.java
@@ -116,7 +116,7 @@ public class _BeansAPI {
             
             packedArgs = new Object[fixedArgCnt + 1]; 
             for (int i = 0; i < fixedArgCnt; i++) {
-                packedArgs[i] = args[i];
+packedArgs[i] = args[i];
             }
             
             final Class<?> compType = 
paramTypes[fixedArgCnt].getComponentType();
@@ -226,5 +226,5 @@ public class _BeansAPI {
     public static ClassIntrospectorBuilder 
getClassIntrospectorBuilder(BeansWrapperConfiguration bwc) {
         return bwc.getClassIntrospectorBuilder();
     }
-    
+
 }
diff --git a/freemarker-core/src/main/javacc/freemarker/core/FTL.jj 
b/freemarker-core/src/main/javacc/freemarker/core/FTL.jj
index f4390862..06495a36 100644
--- a/freemarker-core/src/main/javacc/freemarker/core/FTL.jj
+++ b/freemarker-core/src/main/javacc/freemarker/core/FTL.jj
@@ -2467,6 +2467,9 @@ MethodCall MethodArgs(Expression exp) :
         end = <CLOSE_PAREN>
         {
             args.trimToSize();
+            if (args.isEmpty() && exp instanceof Dot) {
+                exp = new DotBeforeMethodCall((Dot) exp);
+            }
             MethodCall result = new MethodCall(exp, args);
             result.setLocation(template, exp, end);
             return result;
diff --git 
a/freemarker-core16/src/test/java/freemarker/ext/beans/TestZeroArgumentNonVoidMethodPolicy.java
 
b/freemarker-core16/src/test/java/freemarker/ext/beans/TestZeroArgumentNonVoidMethodPolicy.java
new file mode 100644
index 00000000..e5e8916a
--- /dev/null
+++ 
b/freemarker-core16/src/test/java/freemarker/ext/beans/TestZeroArgumentNonVoidMethodPolicy.java
@@ -0,0 +1,187 @@
+/*
+ * 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
+ *
+ *   http://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 freemarker.ext.beans;
+
+import java.io.IOException;
+
+import org.junit.Before;
+import org.junit.Test;
+
+import freemarker.template.Configuration;
+import freemarker.template.DefaultObjectWrapper;
+import freemarker.template.TemplateException;
+import freemarker.test.TemplateTest;
+
+public class TestZeroArgumentNonVoidMethodPolicy extends TemplateTest {
+
+    @Before
+    public void setup() throws Exception {
+        addToDataModel("rec", new TestRecord(1, "S"));
+        addToDataModel("nrc", new TestNonRecord());
+    }
+
+    @Test
+    public void testDefaultWithHighIncompatibleImprovements() throws 
TemplateException, IOException {
+        
super.getConfiguration().setIncompatibleImprovements(Configuration.VERSION_2_3_33);
+        assertRecIsPropertyAndMethod();
+        assertNrcIsMethodOnly();
+    }
+
+    @Test
+    public void testDefaultWithLowIncompatibleImprovements() throws 
TemplateException, IOException {
+        
super.getConfiguration().setIncompatibleImprovements(Configuration.VERSION_2_3_32);
+        assertRecIsMethodOnly();
+        assertNrcIsMethodOnly();
+    }
+
+    @Test
+    public void test() throws TemplateException, IOException {
+        DefaultObjectWrapper beansWrapper = (DefaultObjectWrapper) 
super.getConfiguration().getObjectWrapper();
+        
beansWrapper.setRecordZeroArgumentNonVoidMethodPolicy(ZeroArgumentNonVoidMethodPolicy.PROPERTY_ONLY);
+        assertRecIsPropertyOnly();
+        assertNrcIsMethodOnly();
+    }
+
+    private void assertRecIsPropertyAndMethod() throws IOException, 
TemplateException {
+        assertOutput("${rec.x}", "1");
+        assertOutput("${rec.x()}", "1");
+        assertOutput("${rec.s}", "S");
+        assertOutput("${rec.s()}", "S");
+        assertOutput("${rec.y}", "2");
+        assertOutput("${rec.y()}", "2");
+        assertOutput("${rec.z}", "3");
+        assertErrorContains("${rec.z()}", "SimpleNumber");
+        assertOutput("${rec.getZ()}", "3");
+        assertOutput("${rec.tenX}", "10");
+        assertOutput("${rec.tenX()}", "10");
+        assertOutput("${rec.xTimes(5)}", "5");
+        assertErrorContains("${rec.xTimes}", "SimpleMethodModel");
+    }
+
+    private void assertRecIsMethodOnly() throws IOException, TemplateException 
{
+        assertErrorContains("${rec.x}", "SimpleMethodModel");
+        assertOutput("${rec.x()}", "1");
+        assertErrorContains("${rec.s}", "SimpleMethodModel");
+        assertOutput("${rec.s()}", "S");
+        assertErrorContains("${rec.y}", "SimpleMethodModel");
+        assertOutput("${rec.y()}", "2");
+        assertOutput("${rec.z}", "3");
+        assertErrorContains("${rec.z()}", "SimpleNumber");
+        assertOutput("${rec.getZ()}", "3");
+        assertErrorContains("${rec.tenX}", "SimpleMethodModel");
+        assertOutput("${rec.tenX()}", "10");
+        assertOutput("${rec.xTimes(5)}", "5");
+        assertErrorContains("${rec.xTimes}", "SimpleMethodModel");
+    }
+
+    private void assertRecIsPropertyOnly() throws IOException, 
TemplateException {
+        assertOutput("${rec.x}", "1");
+        assertErrorContains("${rec.x()}", "SimpleNumber");
+        assertOutput("${rec.s}", "S");
+        assertErrorContains("${rec.s()}", "SimpleScalar");
+        assertOutput("${rec.y}", "2");
+        assertErrorContains("${rec.y()}", "SimpleNumber");
+        assertOutput("${rec.z}", "3");
+        assertErrorContains("${rec.z()}", "SimpleNumber");
+        assertOutput("${rec.getZ()}", "3");
+        assertOutput("${rec.tenX}", "10");
+        assertErrorContains("${rec.tenX()}", "SimpleNumber");
+        assertOutput("${rec.xTimes(5)}", "5");
+        assertErrorContains("${rec.xTimes}", "SimpleMethodModel");
+    }
+
+    private void assertNrcIsMethodOnly() throws IOException, TemplateException 
{
+        assertErrorContains("${nrc.x}", "SimpleMethodModel");
+        assertOutput("${nrc.x()}", "1");
+        assertErrorContains("${nrc.y}", "SimpleMethodModel");
+        assertOutput("${nrc.y()}", "2");
+        assertOutput("${nrc.z}", "3");
+        assertErrorContains("${nrc.z()}", "SimpleNumber");
+        assertOutput("${nrc.getZ()}", "3");
+        assertErrorContains("${nrc.tenX}", "SimpleMethodModel");
+        assertOutput("${nrc.tenX()}", "10");
+        assertOutput("${nrc.xTimes(5)}", "5");
+        assertErrorContains("${nrc.xTimes}", "SimpleMethodModel");
+    }
+
+    private void assertNrcIsPropertyAndMethod() throws IOException, 
TemplateException {
+        assertOutput("${nrc.x}", "1");
+        assertOutput("${nrc.x()}", "1");
+        assertOutput("${nrc.y}", "2");
+        assertOutput("${nrc.y()}", "2");
+        assertOutput("${nrc.z}", "3");
+        assertErrorContains("${nrc.z()}", "SimpleNumber");
+        assertOutput("${nrc.getZ()}", "3");
+        assertOutput("${nrc.tenX}", "10");
+        assertOutput("${nrc.tenX()}", "10");
+        assertOutput("${nrc.xTimes(5)}", "5");
+        assertErrorContains("${nrc.xTimes}", "SimpleMethodModel");
+    }
+
+    public record TestRecord(int x, String s) implements TestInterface {
+        @Override
+        public int y() {
+            return 2;
+        }
+
+        @Override
+        public int getZ() {
+            return 3;
+        }
+
+        public int tenX() {
+            return x * 10;
+        }
+
+        public int xTimes(int m) {
+            return x * m;
+        }
+    }
+
+    public interface TestInterface {
+        int y();
+        int getZ();
+    }
+
+    public static class TestNonRecord implements TestInterface {
+        public int x() {
+            return 1;
+        }
+
+        @Override
+        public int y() {
+            return 2;
+        }
+
+        @Override
+        public int getZ() {
+            return 3;
+        }
+
+        public int tenX() {
+            return x() * 10;
+        }
+
+        public int xTimes(int m) {
+            return x() * m;
+        }
+    }
+
+}
diff --git 
a/freemarker-jython25/src/test/java/freemarker/template/DefaultObjectWrapperTest.java
 
b/freemarker-jython25/src/test/java/freemarker/template/DefaultObjectWrapperTest.java
index d67d1950..51152998 100644
--- 
a/freemarker-jython25/src/test/java/freemarker/template/DefaultObjectWrapperTest.java
+++ 
b/freemarker-jython25/src/test/java/freemarker/template/DefaultObjectWrapperTest.java
@@ -104,7 +104,7 @@ public class DefaultObjectWrapperTest {
         expected.add(Configuration.VERSION_2_3_27); // no non-BC change in 
2.3.30
         expected.add(Configuration.VERSION_2_3_27); // no non-BC change in 
2.3.31
         expected.add(Configuration.VERSION_2_3_27); // no non-BC change in 
2.3.32
-        expected.add(Configuration.VERSION_2_3_27); // no non-BC change in 
2.3.33
+        expected.add(Configuration.VERSION_2_3_33);
 
         List<Version> actual = new ArrayList<>();
         for (int i = _VersionInts.V_2_3_0; i <= 
Configuration.getVersion().intValue(); i++) {
@@ -383,7 +383,7 @@ public class DefaultObjectWrapperTest {
             assertTrue(ow.getUseAdaptersForContainers());
             assertTrue(ow.getForceLegacyNonListCollections());
         }
-        
+
         try {
             new DefaultObjectWrapper(new Version(99, 9, 9));
             fail();

Reply via email to