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 6e92cd83472a8274659d5e6d493ba76afedb8c9c
Author: ddekany <[email protected]>
AuthorDate: Sat Jan 13 18:12:11 2024 +0100

    Added support for marking obj.prop and obj.prop() to be the same in 
templates (and equally obj["prop"], and obj["prop"]()). Made Java zero argument 
methods to be such properties by default, if incompatibleImprovements is at 
least 2.3.33. Added ZeroArgumentNonVoidMethodPolicy, and 
BeansWrapperConfiguration.nonRecordZeroArgumentNonVoidMethodPolicy, and 
recordZeroArgumentNonVoidMethodPolicy to implement these. Also,added 
GenericObjectModel which implements MethodCallAwareTemplateHashMode [...]
---
 build.gradle.kts                                   |   2 +-
 .../main/java/freemarker/core/Configurable.java    |   2 +-
 .../src/main/java/freemarker/core/Dot.java         |  21 +-
 .../java/freemarker/core/DotBeforeMethodCall.java  |  59 ++++
 .../main/java/freemarker/core/DynamicKeyName.java  |  16 +-
 .../core/DynamicKeyNameBeforeMethodCall.java       |  50 +++
 .../src/main/java/freemarker/core/MethodCall.java  |   3 +
 .../main/java/freemarker/ext/beans/APIModel.java   |  12 +-
 .../main/java/freemarker/ext/beans/BeanModel.java  | 107 ++++++-
 .../java/freemarker/ext/beans/BeansWrapper.java    | 140 ++++++--
 .../ext/beans/BeansWrapperConfiguration.java       |  36 +++
 .../freemarker/ext/beans/ClassIntrospector.java    |  88 +++++-
 .../ext/beans/ClassIntrospectorBuilder.java        |  48 ++-
 .../ext/beans/FastPropertyDescriptor.java          |  18 +-
 .../freemarker/ext/beans/GenericObjectModel.java   |  71 +++++
 .../ext/beans/MethodAppearanceFineTuner.java       |  20 +-
 .../beans/MethodCallAwareTemplateHashModel.java    | 129 ++++++++
 .../java/freemarker/ext/beans/StringModel.java     |  14 +-
 .../ext/beans/ZeroArgumentNonVoidMethodPolicy.java |  65 ++++
 .../main/java/freemarker/ext/beans/_BeansAPI.java  |   4 +-
 .../src/main/javacc/freemarker/core/FTL.jj         |   7 +
 .../freemarker/template/ConfigurationTest.java     |   4 +-
 .../beans/TestZeroArgumentNonVoidMethodPolicy.java | 352 +++++++++++++++++++++
 .../template/DefaultObjectWrapperTest.java         |   4 +-
 freemarker-manual/src/main/docgen/en_US/book.xml   | 102 ++++++
 .../main/java/freemarker/test/TemplateTest.java    |  15 +-
 26 files changed, 1317 insertions(+), 72 deletions(-)

diff --git a/build.gradle.kts b/build.gradle.kts
index 114ac085..82f8917a 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -61,7 +61,7 @@ freemarkerRoot {
     configureSourceSet("jython20")
     configureSourceSet("jython22")
     configureSourceSet("jython25") { enableTests() }
-    configureSourceSet("core16", "16")
+    configureSourceSet("core16", "16") { enableTests() }
 
     configureGeneratedSourceSet("jakartaServlet") {
         val jakartaSourceGenerators = generateJakartaSources("javaxServlet")
diff --git a/freemarker-core/src/main/java/freemarker/core/Configurable.java 
b/freemarker-core/src/main/java/freemarker/core/Configurable.java
index fc98db58..6eaf8eda 100644
--- a/freemarker-core/src/main/java/freemarker/core/Configurable.java
+++ b/freemarker-core/src/main/java/freemarker/core/Configurable.java
@@ -2553,7 +2553,7 @@ public class Configurable {
      *      <p>If you have no constructor arguments and property setters, and 
the <code><i>className</i></code> class has
      *      a public static {@code INSTANCE} field, the value of that filed 
will be the value of the expression, and
      *      the constructor won't be called. Note that if you use the backward 
compatible
-     *      syntax, where these's no parenthesis after the class name, then it 
will not look for {@code INSTANCE}.
+     *      syntax, where there's no parenthesis after the class name, then it 
will not look for {@code INSTANCE}.
      *   </li>
      *   <li>
      *      <p>If there exists a class named 
<code><i>className</i>Builder</code>, then that class will be instantiated
diff --git a/freemarker-core/src/main/java/freemarker/core/Dot.java 
b/freemarker-core/src/main/java/freemarker/core/Dot.java
index 54bae57b..f360a955 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
+     */
+    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);
+    }
+
+    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..579cb8c6
--- /dev/null
+++ b/freemarker-core/src/main/java/freemarker/core/DotBeforeMethodCall.java
@@ -0,0 +1,59 @@
+/*
+ * 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.ext.beans.ZeroArgumentNonVoidMethodPolicy;
+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 {@link Dot} subclass was added to implement
+ * {@link ZeroArgumentNonVoidMethodPolicy#BOTH_PROPERTY_AND_METHOD}
+ * (via {@link 
BeansWrapper.MethodAppearanceDecision#setMethodInsteadOfPropertyValueBeforeCall(boolean)}).
 We don't
+ * necessarily want to go beyond that hack, as we don't have separate method 
namespace 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/core/DynamicKeyName.java 
b/freemarker-core/src/main/java/freemarker/core/DynamicKeyName.java
index f8cef3a4..d2fa8225 100644
--- a/freemarker-core/src/main/java/freemarker/core/DynamicKeyName.java
+++ b/freemarker-core/src/main/java/freemarker/core/DynamicKeyName.java
@@ -43,7 +43,7 @@ import freemarker.template.utility.Constants;
  * {@code target[keyExpression]}, where, in FM 2.3, {@code keyExpression} can 
be string, a number or a range,
  * and {@code target} can be a hash or a sequence.
  */
-final class DynamicKeyName extends Expression {
+class DynamicKeyName extends Expression {
 
     private static final int UNKNOWN_RESULT_SIZE = -1;
 
@@ -58,6 +58,13 @@ final class DynamicKeyName extends Expression {
         target.enableLazilyGeneratedResult();
     }
 
+    DynamicKeyName(DynamicKeyName dynamicKeyName) {
+        this(dynamicKeyName.target, dynamicKeyName.keyExpression);
+        this.lazilyGeneratedResultEnabled = 
dynamicKeyName.lazilyGeneratedResultEnabled;
+        this.constantValue = dynamicKeyName.constantValue; // Probably always 
will be null here
+        copyFieldsFrom(dynamicKeyName);
+    }
+
     @Override
     TemplateModel _eval(Environment env) throws TemplateException {
         TemplateModel targetModel = target.eval(env);
@@ -163,11 +170,16 @@ final class DynamicKeyName extends Expression {
     private TemplateModel dealWithStringKey(TemplateModel targetModel, String 
key, Environment env)
         throws TemplateException {
         if (targetModel instanceof TemplateHashModel) {
-            return((TemplateHashModel) targetModel).get(key);
+            return getFromHashModelWithStringKey((TemplateHashModel) 
targetModel, key);
         }
         throw new NonHashException(target, targetModel, env);
     }
 
+    protected TemplateModel getFromHashModelWithStringKey(TemplateHashModel 
targetModel, String key)
+            throws TemplateException {
+        return targetModel.get(key);
+    }
+
     private TemplateModel dealWithRangeKey(TemplateModel targetModel, 
RangeModel range, Environment env)
     throws TemplateException {
         // We can have 3 kind of left hand operands ("targets"): sequence, 
lazily generated sequence, string
diff --git 
a/freemarker-core/src/main/java/freemarker/core/DynamicKeyNameBeforeMethodCall.java
 
b/freemarker-core/src/main/java/freemarker/core/DynamicKeyNameBeforeMethodCall.java
new file mode 100644
index 00000000..c40c71f4
--- /dev/null
+++ 
b/freemarker-core/src/main/java/freemarker/core/DynamicKeyNameBeforeMethodCall.java
@@ -0,0 +1,50 @@
+/*
+ * 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.MethodCallAwareTemplateHashModel;
+import freemarker.template.TemplateException;
+import freemarker.template.TemplateHashModel;
+import freemarker.template.TemplateModel;
+
+class DynamicKeyNameBeforeMethodCall extends DynamicKeyName {
+    DynamicKeyNameBeforeMethodCall(DynamicKeyName dynamicKeyName) {
+        super(dynamicKeyName);
+    }
+
+    @Override
+    protected TemplateModel getFromHashModelWithStringKey(TemplateHashModel 
targetModel, String key)
+            throws TemplateException {
+        if (targetModel instanceof MethodCallAwareTemplateHashModel) {
+            try {
+                return ((MethodCallAwareTemplateHashModel) 
targetModel).getBeforeMethodCall(key);
+            } 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.getFromHashModelWithStringKey(targetModel, key);
+        }
+    }
+}
diff --git a/freemarker-core/src/main/java/freemarker/core/MethodCall.java 
b/freemarker-core/src/main/java/freemarker/core/MethodCall.java
index 2ece2a0a..1eebbe59 100644
--- a/freemarker-core/src/main/java/freemarker/core/MethodCall.java
+++ b/freemarker-core/src/main/java/freemarker/core/MethodCall.java
@@ -66,6 +66,9 @@ final class MethodCall extends Expression {
         } else {
             throw new NonMethodException(target, targetModel, true, false, 
null, env);
         }
+        // ATTENTION! If you add support for calling any new type, ensure that
+        // freemarker.ext.beans.BeanModel.invokeThroughDescriptor sees that 
type as callable too,
+        // where it deals with the beforeMethodCall logic!
     }
 
     @Override
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..5f1274be 100644
--- a/freemarker-core/src/main/java/freemarker/ext/beans/APIModel.java
+++ b/freemarker-core/src/main/java/freemarker/ext/beans/APIModel.java
@@ -19,6 +19,9 @@
 
 package freemarker.ext.beans;
 
+import freemarker.template.TemplateModel;
+import freemarker.template.TemplateModelException;
+
 /**
  * Exposes the Java API (and properties) of an object.
  * 
@@ -32,7 +35,7 @@ package freemarker.ext.beans;
  * 
  * @since 2.3.22
  */
-final class APIModel extends BeanModel {
+final class APIModel extends BeanModel implements 
MethodCallAwareTemplateHashModel {
 
     APIModel(Object object, BeansWrapper wrapper) {
         super(object, wrapper, false);
@@ -41,5 +44,10 @@ final class APIModel extends BeanModel {
     protected boolean isMethodsShadowItems() {
         return true;
     }
-    
+
+    @Override
+    public TemplateModel 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..88af1f56 100644
--- a/freemarker-core/src/main/java/freemarker/ext/beans/BeanModel.java
+++ b/freemarker-core/src/main/java/freemarker/ext/beans/BeanModel.java
@@ -30,7 +30,9 @@ import java.util.List;
 import java.util.Map;
 import java.util.Set;
 
+import freemarker.core.BugException;
 import freemarker.core.CollectionAndSequence;
+import freemarker.core.Macro;
 import freemarker.core._DelayedFTLTypeDescription;
 import freemarker.core._DelayedJQuote;
 import freemarker.core._TemplateModelException;
@@ -43,11 +45,14 @@ import freemarker.template.SimpleScalar;
 import freemarker.template.SimpleSequence;
 import freemarker.template.TemplateCollectionModel;
 import freemarker.template.TemplateHashModelEx;
+import freemarker.template.TemplateMethodModel;
+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 +139,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 TemplateModel 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)}.
+     *      This parameter is {@code false} when {@link #get(String)} is 
called, and
+     *      {@code true} when {@link 
MethodCallAwareTemplateHashModel#getBeforeMethodCall(String)} is called.
+     *      If this is {@code true}, this method should return a {@link 
TemplateMethodModelEx}, or {@code null},
+     *      or fail with {@link 
MethodCallAwareTemplateHashModel.ShouldNotBeGetAsMethodException}.
+     *
+     * @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 +208,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 +220,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
@@ -178,9 +238,12 @@ implements TemplateHashModelEx, AdapterTemplateModel, 
WrapperTemplateModel, Temp
                 retval = wrapper.wrap(null);
             }
             return retval;
-        } catch (TemplateModelException e) {
+        } catch (TemplateModelException | 
MethodCallAwareTemplateHashModel.ShouldNotBeGetAsMethodException 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 +266,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) {
@@ -215,6 +279,9 @@ implements TemplateHashModelEx, AdapterTemplateModel, 
WrapperTemplateModel, Temp
             return cachedModel;
         }
 
+        // ATTENTION! As the value of beforeMethodCall is not part of the 
cache lookup key, it's very important that we
+        // don't cache the value for desc-s where beforeMethodCall can have 
influence on the result!
+
         TemplateModel resultModel = UNKNOWN;
         if (desc instanceof FastPropertyDescriptor) {
             FastPropertyDescriptor pd = (FastPropertyDescriptor) desc;
@@ -229,8 +296,30 @@ 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
+                // cachedModel must remains null in this branch, because the 
result is influenced by beforeMethodCall,
+                // which wasn't part of the cache key!
+
+                if (!beforeMethodCall) {
+                    resultModel = wrapper.invokeMethod(object, 
pd.getReadMethod(), null);
+                    // cachedModel remains null, as we don't cache these
+                } else {
+                    if (pd.isMethodInsteadOfPropertyValueBeforeCall()) {
+                        // Do not cache this result! See comments earlier!
+                        resultModel = new SimpleMethodModel(
+                                object, pd.getReadMethod(), 
CollectionUtils.EMPTY_CLASS_ARRAY, wrapper);
+                    } else {
+                        resultModel = wrapper.invokeMethod(object, 
pd.getReadMethod(), null);
+
+                        // Checks if freemarker.core.MethodCall would accept 
this result:
+                        if (!(resultModel instanceof TemplateMethodModel || 
resultModel instanceof Macro)) {
+                            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..0fbf3561 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;
@@ -153,7 +154,7 @@ public class BeansWrapper implements RichObjectWrapper, 
WriteProtectable {
      * performance. In theory that's not needed, but apps might fail to keep 
the rules.
      */
     private ClassIntrospector classIntrospector;
-    
+
     /**
      * {@link String} class name to {@link StaticModel} cache.
      * This object only belongs to a single {@link BeansWrapper}.
@@ -193,9 +194,10 @@ 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 Version incompatibleImprovements;
-    
+
+
     /**
      * Creates a new instance with the incompatible-improvements-version 
specified in
      * {@link Configuration#DEFAULT_INCOMPATIBLE_IMPROVEMENTS}.
@@ -262,6 +264,16 @@ 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):
+     *       The default of {@link 
BeansWrapper#setRecordZeroArgumentNonVoidMethodPolicy(ZeroArgumentNonVoidMethodPolicy)}
+     *       has changes to {@link 
ZeroArgumentNonVoidMethodPolicy#BOTH_PROPERTY_AND_METHOD}, from
+     *       {@link ZeroArgumentNonVoidMethodPolicy#METHOD_ONLY}. This means 
that Java records public methods with
+     *       0-arguments and 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).
+     *     </li>
      *   </ul>
      *   
      *   <p>Note that the version will be normalized to the lowest version 
where the same incompatible
@@ -289,7 +301,7 @@ public class BeansWrapper implements RichObjectWrapper, 
WriteProtectable {
     }
     
     /**
-     * Initializes the instance based on the the {@link 
BeansWrapperConfiguration} specified.
+     * Initializes the instance based on the {@link BeansWrapperConfiguration} 
specified.
      * 
      * @param writeProtected Makes the instance's configuration settings 
read-only via
      *     {@link WriteProtectable#writeProtect()}; this way it can use the 
shared class introspection cache.
@@ -320,7 +332,7 @@ public class BeansWrapper implements RichObjectWrapper, 
WriteProtectable {
                 }
             } catch (Throwable e) {
                 // The security manager sometimes doesn't allow this
-                LOG.info("Failed to check if finetuneMethodAppearance is 
overidden in " + thisClass.getName()
+                LOG.info("Failed to check if finetuneMethodAppearance is 
overridden in " + thisClass.getName()
                         + "; acting like if it was, but this way it won't 
utilize the shared class introspection "
                         + "cache.",
                         e);
@@ -353,7 +365,7 @@ public class BeansWrapper implements RichObjectWrapper, 
WriteProtectable {
         defaultDateType = bwConf.getDefaultDateType();
         outerIdentity = bwConf.getOuterIdentity() != null ? 
bwConf.getOuterIdentity() : this;
         strict = bwConf.isStrict();
-        
+
         if (!writeProtected) {
             // As this is not a read-only BeansWrapper, the classIntrospector 
will be possibly replaced for a few times,
             // but we need to use the same sharedInrospectionLock forever, 
because that's what the model factories
@@ -367,7 +379,7 @@ public class BeansWrapper implements RichObjectWrapper, 
WriteProtectable {
             classIntrospector = 
_BeansAPI.getClassIntrospectorBuilder(bwConf).build();
             sharedIntrospectionLock = classIntrospector.getSharedLock(); 
         }
-        
+
         falseModel = new BooleanModel(Boolean.FALSE, this);
         trueModel = new BooleanModel(Boolean.TRUE, this);
         
@@ -633,7 +645,45 @@ public class BeansWrapper implements RichObjectWrapper, 
WriteProtectable {
             replaceClassIntrospector(builder);
         }
     }
-    
+
+    /**
+     * Sets the {@link ZeroArgumentNonVoidMethodPolicy} for classes that are 
not Java records;
+     * defaults to {@link ZeroArgumentNonVoidMethodPolicy#METHOD_ONLY}.
+     *
+     * <p>Note that methods in this class are inherited by {@link 
DefaultObjectWrapper}, which is what you normally use.
+     *
+     * @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 
Java records; if the
+     * {@code BeansWrapper#BeansWrapper(Version) incompatibleImprovements} of 
the object wrapper is at least 2.3.33,
+     * then it defaults to {@link 
ZeroArgumentNonVoidMethodPolicy#BOTH_PROPERTY_AND_METHOD}, otherwise it 
defaults to
+     * {@link ZeroArgumentNonVoidMethodPolicy#METHOD_ONLY}.
+     *
+     * <p>Note that methods in this class are inherited by {@link 
DefaultObjectWrapper}, which is what you normally use.
+     *
+     * @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 +701,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 +933,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 +962,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
@@ -937,7 +1006,7 @@ public class BeansWrapper implements RichObjectWrapper, 
WriteProtectable {
      * <li>if the object is an Iterator, returns a {@link IteratorModel} for it
      * <li>if the object is an Enumeration, returns a {@link EnumerationModel} 
for it
      * <li>if the object is a String, returns a {@link StringModel} for it
-     * <li>otherwise, returns a generic {@link StringModel} for it.
+     * <li>otherwise, returns a {@link GenericObjectModel} for it.
      * </ul>
      */
     @Override
@@ -1033,7 +1102,7 @@ public class BeansWrapper implements RichObjectWrapper, 
WriteProtectable {
         if (clazz.isArray()) {
             return ArrayModel.FACTORY;
         }
-        return StringModel.FACTORY;
+        return GenericObjectModel.FACTORY;
     }
 
     /**
@@ -1855,15 +1924,33 @@ 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;
-            replaceExistingProperty = false;
-            exposeMethodAs = m.getName();
+
+        /**
+         * @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);
+                }
+                methodInsteadOfPropertyValueBeforeCall = 
appliedZeroArgumentNonVoidMethodPolicy ==
+                        
ZeroArgumentNonVoidMethodPolicy.BOTH_PROPERTY_AND_METHOD;
+            } else {
+                exposeAsProperty = null;
+                methodInsteadOfPropertyValueBeforeCall = false;
+            }
+            exposeMethodAs = appliedZeroArgumentNonVoidMethodPolicy != 
ZeroArgumentNonVoidMethodPolicy.PROPERTY_ONLY
+                    ? m.getName() : null;
             methodShadowsProperty = true;
+            replaceExistingProperty = false;
         }
         
         /**
@@ -1935,6 +2022,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..a49fabbc 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,42 @@ public abstract class BeansWrapperConfiguration implements 
Cloneable {
         
classIntrospectorBuilder.setTreatDefaultMethodsAsBeanMembers(treatDefaultMethodsAsBeanMembers);
     }
 
+    /**
+     * @since 2.3.33
+     */
+    public ZeroArgumentNonVoidMethodPolicy 
getNonRecordZeroArgumentNonVoidMethodPolicy() {
+        return 
classIntrospectorBuilder.getNonRecordZeroArgumentNonVoidMethodPolicy();
+    }
+
+    /**
+     * See {@link 
BeansWrapper#setNonRecordZeroArgumentNonVoidMethodPolicy(ZeroArgumentNonVoidMethodPolicy)}.
+     *
+     * <p>Note that methods in this class are inherited by {@link 
DefaultObjectWrapperBuilder}, which is what you normally use.
+     *
+     * @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)}
+     *
+     * <p>Note that methods in this class are inherited by {@link 
DefaultObjectWrapperBuilder}, which is what you normally use.
+     *
+     * @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..81f4ffe0 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();
@@ -359,24 +389,31 @@ class ClassIntrospector {
                         methodAppearanceFineTuner.process(decisionInput, 
decision);
                     }
 
+                    String exposedMethodName = decision.getExposeMethodAs();
+
                     PropertyDescriptor propDesc = 
decision.getExposeAsProperty();
                     if (propDesc != null &&
                             (decision.getReplaceExistingProperty()
                                     || !(introspData.get(propDesc.getName()) 
instanceof FastPropertyDescriptor))) {
+                        boolean methodInsteadOfPropertyValueBeforeCall = 
decision.isMethodInsteadOfPropertyValueBeforeCall();
                         addPropertyDescriptorToClassIntrospectionData(
-                                introspData, propDesc, accessibleMethods, 
effClassMemberAccessPolicy);
+                                introspData, propDesc, 
methodInsteadOfPropertyValueBeforeCall,
+                                accessibleMethods, null, 
effClassMemberAccessPolicy);
+                        if (methodInsteadOfPropertyValueBeforeCall
+                                && exposedMethodName != null && 
exposedMethodName.equals(propDesc.getName())) {
+                            exposedMethodName = null; // We have already 
exposed this as property with the method name
+                        }
                     }
 
-                    String methodKey = decision.getExposeMethodAs();
-                    if (methodKey != null) {
-                        Object previous = introspData.get(methodKey);
+                    if (exposedMethodName != null) {
+                        Object previous = introspData.get(exposedMethodName);
                         if (previous instanceof Method) {
                             // Overloaded method - replace Method with a 
OverloadedMethods
                             OverloadedMethods overloadedMethods =
                                     new OverloadedMethods(is2321Bugfixed());
                             overloadedMethods.addMethod((Method) previous);
                             overloadedMethods.addMethod(method);
-                            introspData.put(methodKey, overloadedMethods);
+                            introspData.put(exposedMethodName, 
overloadedMethods);
                             // Remove parameter type information (unless an 
indexed property reader needs it):
                             if (argTypesUsedByIndexerPropReaders == null
                                     || 
!argTypesUsedByIndexerPropReaders.containsKey(previous)) {
@@ -388,7 +425,7 @@ class ClassIntrospector {
                         } else if (decision.getMethodShadowsProperty()
                                 || !(previous instanceof 
FastPropertyDescriptor)) {
                             // Simple method (so far)
-                            introspData.put(methodKey, method);
+                            introspData.put(exposedMethodName, method);
                             Class<?>[] replaced = 
getArgTypesByMethod(introspData).put(method,
                                     method.getParameterTypes());
                             if (replaced != null) {
@@ -404,6 +441,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 +722,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 +747,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 +1132,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/GenericObjectModel.java 
b/freemarker-core/src/main/java/freemarker/ext/beans/GenericObjectModel.java
new file mode 100644
index 00000000..dd6fd165
--- /dev/null
+++ b/freemarker-core/src/main/java/freemarker/ext/beans/GenericObjectModel.java
@@ -0,0 +1,71 @@
+/*
+ * 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.util.Collection;
+import java.util.Map;
+
+import freemarker.ext.util.ModelFactory;
+import freemarker.template.TemplateModel;
+import freemarker.template.TemplateModelException;
+
+/**
+ * This is used for wrapping objects that has no special treatment ({@link 
Map}-s, {@link Collection}-s,
+ * {@link Number}-s, {@link Boolean}-s, and some more commonly have), hence 
they are just "generic" Java
+ * objects. Users usually just want to call the public Java methods on such 
objects.
+ * These objects can also be used as string values in templates, and that 
value is provided by
+ * the {@link Object#toString()} method of the wrapped object.
+ *
+ * <p>This extends {@link StringModel} for backward compatibility, as now 
{@link BeansWrapper} returns instances of
+ * {@link GenericObjectModel} instead of {@link StringModel}-s, but user code 
may have {@code insteanceof StringModel},
+ * or casing to {@link StringModel}. {@link StringModel} served the same 
purpose as this class, but didn't implement
+ * {@link MethodCallAwareTemplateHashModel}.
+ *
+ * @since 2.3.33
+ */
+public class GenericObjectModel extends StringModel implements 
MethodCallAwareTemplateHashModel {
+    static final ModelFactory FACTORY = (object, wrapper) -> new 
GenericObjectModel(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 GenericObjectModel(Object object, BeansWrapper wrapper) {
+        super(object, wrapper);
+    }
+
+    // Made this final, to ensure that users override get(key, boolean) 
instead.
+    @Override
+    public final TemplateModel get(String key) throws TemplateModelException {
+        return super.get(key);
+    }
+
+    @Override
+    public TemplateModel getBeforeMethodCall(String key) throws 
TemplateModelException,
+            ShouldNotBeGetAsMethodException {
+        return super.getBeforeMethodCall(key);
+    }
+}
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..bfa6bc9f 100644
--- 
a/freemarker-core/src/main/java/freemarker/ext/beans/MethodAppearanceFineTuner.java
+++ 
b/freemarker-core/src/main/java/freemarker/ext/beans/MethodAppearanceFineTuner.java
@@ -53,7 +53,10 @@ public interface MethodAppearanceFineTuner {
      *   <li>Show the method with a different name in the data-model than its
      *     real name by calling
      *     {@link MethodAppearanceDecision#setExposeMethodAs(String)}
-     *     with non-{@code null} parameter.
+     *     with non-{@code null} parameter. Also, if set to {@code null}, the 
method won't be exposed.
+     *     The default is the name of the method. Note that if {@code 
methodInsteadOfPropertyValueBeforeCall} is
+     *     {@code true}, the method is not exposed if the method name set here 
is the same as the name of the property
+     *     set for this method with {@link 
MethodAppearanceDecision#setExposeAsProperty(PropertyDescriptor)}.
      *   <li>Create a fake JavaBean property for this method by calling
      *     {@link 
MethodAppearanceDecision#setExposeAsProperty(PropertyDescriptor)}.
      *     For example, if you have {@code int size()} in a class, but you
@@ -76,6 +79,21 @@ 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.
+     *     The default of this is influenced by
+     *     {@link 
BeansWrapperConfiguration#setNonRecordZeroArgumentNonVoidMethodPolicy(ZeroArgumentNonVoidMethodPolicy)},
+     *     {@link 
BeansWrapperConfiguration#setRecordZeroArgumentNonVoidMethodPolicy(ZeroArgumentNonVoidMethodPolicy)}.
      *   <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..337a8828
--- /dev/null
+++ 
b/freemarker-core/src/main/java/freemarker/ext/beans/MethodCallAwareTemplateHashModel.java
@@ -0,0 +1,129 @@
+/*
+ * 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.util.Collection;
+import java.util.Map;
+
+import freemarker.core.Macro;
+import freemarker.core.NonMethodException;
+import freemarker.template.DefaultObjectWrapper;
+import freemarker.template.ObjectWrapper;
+import freemarker.template.TemplateHashModel;
+import freemarker.template.TemplateMethodModel;
+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,
+ * which is needed to implement {@link 
ZeroArgumentNonVoidMethodPolicy#BOTH_PROPERTY_AND_METHOD}.
+ *
+ * <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)}).
+ *
+ * <p>Objects wrapped with {@link BeansWrapper}, and {@link 
DefaultObjectWrapper} will implement this interface, when
+ * they are "generic" object (that is, when they are not classes with special 
wrapper, like {@link Map}-s,
+ * {@link Collection}-s, {@link Number}-s, etc.).
+ *
+ * @since 2.3.33
+ */
+public interface MethodCallAwareTemplateHashModel extends TemplateHashModel {
+
+    /**
+     * This is called instead of {@link #get(String)}, if we know that the 
return value should be callable like 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 it should 
return a
+     *      {@link TemplateMethodModelEx}, or a {@link TemplateMethodModel}, 
or in very rare case a {@link Macro}
+     *      that was created with the {@code function} directive. Or, {@code 
null} in the same case as
+     *      {@link #get(String)}. The method should never return something 
that's not callable in the template language
+     *      as a method or function.
+     *
+     * @throws ShouldNotBeGetAsMethodException
+     *      If the value for the given key exists, but it shouldn't be coerced 
something callable as a 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.
+     */
+    TemplateModel 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..c1052a01 100644
--- a/freemarker-core/src/main/java/freemarker/ext/beans/StringModel.java
+++ b/freemarker-core/src/main/java/freemarker/ext/beans/StringModel.java
@@ -20,25 +20,19 @@
 package freemarker.ext.beans;
 
 import freemarker.ext.util.ModelFactory;
-import freemarker.template.ObjectWrapper;
-import freemarker.template.TemplateModel;
 import freemarker.template.TemplateScalarModel;
 
 /**
  * Subclass of {@link BeanModel} that exposes the return value of the {@link
  * java.lang.Object#toString()} method through the {@link TemplateScalarModel}
  * interface.
+ *
+ * @deprecated Use {@link GenericObjectModel} instead, which implements {@link 
MethodCallAwareTemplateHashModel}.
  */
+@Deprecated
 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/ZeroArgumentNonVoidMethodPolicy.java
 
b/freemarker-core/src/main/java/freemarker/ext/beans/ZeroArgumentNonVoidMethodPolicy.java
new file mode 100644
index 00000000..85f2390a
--- /dev/null
+++ 
b/freemarker-core/src/main/java/freemarker/ext/beans/ZeroArgumentNonVoidMethodPolicy.java
@@ -0,0 +1,65 @@
+/*
+ * 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.template.DefaultObjectWrapper;
+
+/**
+ * How to show 0 argument non-void public methods to templates, which are not 
standard Java Beans read methods.
+ * Used in {@link BeansWrapper}, and therefore in {@link DefaultObjectWrapper}.
+ * This policy doesn't apply to methods that Java Beans introspector discovers 
as a property read method (which
+ * typically look like {@code getSomething()}/{@code getSomething()}). It's 
only applicable to methods like
+ * {@code something()}, including the component read methods of Java records.
+ *
+ * @see 
BeansWrapperConfiguration#setNonRecordZeroArgumentNonVoidMethodPolicy(ZeroArgumentNonVoidMethodPolicy)
+ * @see 
BeansWrapperConfiguration#setRecordZeroArgumentNonVoidMethodPolicy(ZeroArgumentNonVoidMethodPolicy)
+ * @see 
BeansWrapper.MethodAppearanceDecision#setMethodInsteadOfPropertyValueBeforeCall(boolean)
+ *
+ * @since 2.3.33
+ */
+public enum ZeroArgumentNonVoidMethodPolicy {
+
+    /**
+     * Both {@code obj.m}, and {@code obj.m()} gives back the value that the 
{@code m} Java method returns, and it's
+     * not possible to get the method itself.
+     *
+     * <p>This is a parse-time trick that only works when the result of the 
dot operator is called immediately in a
+     * template (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 in the Java ecosystem (and is a 
standard in some other JVM languages),
+     * 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 be allowing both, 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.
+     */
+    BOTH_PROPERTY_AND_METHOD,
+
+    /**
+     * Only {@code obj.m()} gives back the value, {@code obj.m} just gives the 
method itself.
+     */
+    METHOD_ONLY,
+
+    /**
+     * {@code obj.m} in gives back the value, and the method itself can't be 
get.
+     */
+    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..e4950431 100644
--- a/freemarker-core/src/main/javacc/freemarker/core/FTL.jj
+++ b/freemarker-core/src/main/javacc/freemarker/core/FTL.jj
@@ -2467,6 +2467,13 @@ MethodCall MethodArgs(Expression exp) :
         end = <CLOSE_PAREN>
         {
             args.trimToSize();
+            if (args.isEmpty()) {
+                if (exp instanceof Dot) {
+                    exp = new DotBeforeMethodCall((Dot) exp);
+                } else if (exp instanceof DynamicKeyName) {
+                    exp = new DynamicKeyNameBeforeMethodCall((DynamicKeyName) 
exp);
+                }
+            }
             MethodCall result = new MethodCall(exp, args);
             result.setLocation(template, exp, end);
             return result;
diff --git 
a/freemarker-core/src/test/java/freemarker/template/ConfigurationTest.java 
b/freemarker-core/src/test/java/freemarker/template/ConfigurationTest.java
index 6a08ea91..4f4e6f94 100644
--- a/freemarker-core/src/test/java/freemarker/template/ConfigurationTest.java
+++ b/freemarker-core/src/test/java/freemarker/template/ConfigurationTest.java
@@ -90,10 +90,10 @@ import freemarker.core.XMLOutputFormat;
 import freemarker.core.XSCFormat;
 import freemarker.core._CoreStringUtils;
 import freemarker.ext.beans.BeansWrapperBuilder;
+import freemarker.ext.beans.GenericObjectModel;
 import freemarker.ext.beans.LegacyDefaultMemberAccessPolicy;
 import freemarker.ext.beans.MemberAccessPolicy;
 import freemarker.ext.beans.MemberSelectorListMemberAccessPolicy;
-import freemarker.ext.beans.StringModel;
 import freemarker.ext.beans.WhitelistMemberAccessPolicy;
 import freemarker.template.utility.DateUtil;
 import freemarker.template.utility.NullArgumentException;
@@ -1316,7 +1316,7 @@ public class ConfigurationTest extends TestCase {
         {
             TemplateScalarModel aVal = (TemplateScalarModel) 
cfg.getSharedVariable("a");
             assertEquals("aa", aVal.getAsString());
-            assertEquals(StringModel.class, aVal.getClass());
+            assertEquals(GenericObjectModel.class, aVal.getClass());
             
             TemplateScalarModel bVal = (TemplateScalarModel) 
cfg.getSharedVariable("b");
             assertEquals("bbLegacy", bVal.getAsString());
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..56b80d53
--- /dev/null
+++ 
b/freemarker-core16/src/test/java/freemarker/ext/beans/TestZeroArgumentNonVoidMethodPolicy.java
@@ -0,0 +1,352 @@
+/*
+ * 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 static freemarker.template.Configuration.*;
+
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.List;
+import java.util.function.Supplier;
+import java.util.regex.Pattern;
+
+import org.junit.Test;
+
+import freemarker.template.Configuration;
+import freemarker.template.DefaultObjectWrapper;
+import freemarker.template.ObjectWrapper;
+import freemarker.template.SimpleHash;
+import freemarker.template.TemplateException;
+import freemarker.test.TemplateTest;
+
+public class TestZeroArgumentNonVoidMethodPolicy extends TemplateTest {
+    private static final Pattern DOT_REPLACE_PATTERN = 
Pattern.compile("\\.(\\w+)");
+
+    private static String withDotOrSquareBracket(String s, boolean dot) {
+        if (dot) {
+            return s;
+        }
+        return DOT_REPLACE_PATTERN.matcher(s).replaceFirst(key -> "['" + 
key.group(1) + "']");
+    }
+
+    @Override
+    protected Configuration createConfiguration() throws Exception {
+        Configuration cfg = super.createConfiguration();
+        // Don't use default, as then the object wrapper is a shared static 
mutable object:
+        cfg.setIncompatibleImprovements(Configuration.VERSION_2_3_32);
+        return cfg;
+    }
+
+    @Test
+    public void testDefaultWithHighIncompatibleImprovements() throws 
TemplateException, IOException {
+        for (boolean cacheTopLevelVars : List.of(true, false)){
+            setupDataModel(
+                    () -> new DefaultObjectWrapper(VERSION_2_3_33),
+                    cacheTopLevelVars);
+            assertRecIsBothPropertyAndMethod();
+            assertNrcIsMethodOnly();
+        }
+    }
+
+    @Test
+    public void testDefaultWithLowIncompatibleImprovements() throws 
TemplateException, IOException {
+        for (boolean cacheTopLevelVars : List.of(true, false)) {
+            setupDataModel(
+                    () -> new DefaultObjectWrapper(VERSION_2_3_32),
+                    cacheTopLevelVars);
+            assertRecIsMethodOnly();
+            assertNrcIsMethodOnly();
+        }
+    }
+
+    @Test
+    public void testDefaultWithLowIncompatibleImprovements2() throws 
TemplateException, IOException {
+        for (boolean cacheTopLevelVars : List.of(true, false)) {
+            setupDataModel(
+                    () -> {
+                        DefaultObjectWrapper beansWrapper = new 
DefaultObjectWrapper(VERSION_2_3_32);
+                        beansWrapper.setRecordZeroArgumentNonVoidMethodPolicy(
+                                
ZeroArgumentNonVoidMethodPolicy.BOTH_PROPERTY_AND_METHOD);
+                        return beansWrapper;
+                    },
+                    cacheTopLevelVars);
+            assertRecIsBothPropertyAndMethod();
+            assertNrcIsMethodOnly();
+        }
+    }
+
+    @Test
+    public void testDefaultWithRecordsPropertyOnly() throws TemplateException, 
IOException {
+        for (boolean cacheTopLevelVars : List.of(true, false)) {
+            setupDataModel(
+                    () -> {
+                        DefaultObjectWrapper beansWrapper = new 
DefaultObjectWrapper(VERSION_2_3_32);
+                        
beansWrapper.setRecordZeroArgumentNonVoidMethodPolicy(ZeroArgumentNonVoidMethodPolicy.PROPERTY_ONLY);
+                        return beansWrapper;
+                    },
+                    cacheTopLevelVars);
+            assertRecIsPropertyOnly();
+            assertNrcIsMethodOnly();
+        }
+    }
+
+    @Test
+    public void testDefaultWithRecordsPropertyOnly2() throws 
TemplateException, IOException {
+        for (boolean cacheTopLevelVars : List.of(true, false)) {
+            setupDataModel(
+                    () -> {
+                        DefaultObjectWrapper beansWrapper = new 
DefaultObjectWrapper(VERSION_2_3_33);
+                        
beansWrapper.setRecordZeroArgumentNonVoidMethodPolicy(ZeroArgumentNonVoidMethodPolicy.PROPERTY_ONLY);
+                        return beansWrapper;
+                    },
+                    cacheTopLevelVars);
+            assertRecIsPropertyOnly();
+            assertNrcIsMethodOnly();
+        }
+    }
+
+    @Test
+    public void testDefaultWithNonRecordsPropertyOnly() throws 
TemplateException, IOException {
+        for (boolean cacheTopLevelVars : List.of(true, false)) {
+            setupDataModel(
+                    () -> {
+                        DefaultObjectWrapper beansWrapper = new 
DefaultObjectWrapper(VERSION_2_3_32);
+                        
beansWrapper.setNonRecordZeroArgumentNonVoidMethodPolicy(ZeroArgumentNonVoidMethodPolicy.PROPERTY_ONLY);
+                        return beansWrapper;
+                    },
+                    cacheTopLevelVars);
+            assertRecIsMethodOnly();
+            assertNrcIsPropertyOnly();
+        }
+    }
+
+    @Test
+    public void testDefaultWithBothPropertyAndMethod() throws 
TemplateException, IOException {
+        for (boolean cacheTopLevelVars : List.of(true, false)) {
+            setupDataModel(
+                    () -> {
+                        DefaultObjectWrapper beansWrapper = new 
DefaultObjectWrapper(VERSION_2_3_33);
+                        
beansWrapper.setNonRecordZeroArgumentNonVoidMethodPolicy(
+                                
ZeroArgumentNonVoidMethodPolicy.BOTH_PROPERTY_AND_METHOD);
+                        return beansWrapper;
+                    },
+                    cacheTopLevelVars);
+            assertRecIsBothPropertyAndMethod();
+            assertNrcIsBothPropertyAndMethod();
+        }
+    }
+
+    @Test
+    public void testSettings() throws TemplateException, IOException {
+            getConfiguration().setSetting(
+                    "objectWrapper",
+                    "DefaultObjectWrapper(2.3.33, 
nonRecordZeroArgumentNonVoidMethodPolicy=freemarker.ext.beans.ZeroArgumentNonVoidMethodPolicy.BOTH_PROPERTY_AND_METHOD)");
+            setupDataModel(() -> getConfiguration().getObjectWrapper(), false);
+            assertRecIsBothPropertyAndMethod();
+            assertNrcIsBothPropertyAndMethod();
+    }
+
+    private void setupDataModel(Supplier<? extends ObjectWrapper> 
objectWrapperSupplier, boolean cacheTopLevelVars) {
+        ObjectWrapper objectWrapper = objectWrapperSupplier.get();
+        getConfiguration().setObjectWrapper(objectWrapper);
+
+        setDataModel(cacheTopLevelVars ? new SimpleHash(objectWrapper) : new 
HashMap<>());
+
+        addToDataModel("rec", new TestRecord(1, "S"));
+        addToDataModel("nrc", new TestNonRecord());
+    }
+
+    private void assertRecIsBothPropertyAndMethod() throws IOException, 
TemplateException {
+        for (boolean dot : List.of(true, false)) {
+            assertOutput(withDotOrSquareBracket("${rec.x}", dot), "1");
+            assertOutput(withDotOrSquareBracket("${rec.x()}", dot), "1");
+            assertOutput(withDotOrSquareBracket("${rec.s}", dot), "S");
+            assertOutput(withDotOrSquareBracket("${rec.s()}", dot), "S");
+            assertOutput(withDotOrSquareBracket("${rec.y}", dot), "2");
+            assertOutput(withDotOrSquareBracket("${rec.y()}", dot), "2");
+            assertOutput(withDotOrSquareBracket("${rec.tenX}", dot), "10");
+            assertOutput(withDotOrSquareBracket("${rec.tenX()}", dot), "10");
+        }
+        assertRecPolicyIndependentMembers();
+    }
+
+    private void assertRecIsMethodOnly() throws IOException, TemplateException 
{
+        for (boolean dot : List.of(true, false)) {
+            assertErrorContains(withDotOrSquareBracket("${rec.x}", dot), 
"SimpleMethodModel");
+            assertOutput(withDotOrSquareBracket("${rec.x()}", dot), "1");
+            assertErrorContains(withDotOrSquareBracket("${rec.s}", dot), 
"SimpleMethodModel");
+            assertOutput(withDotOrSquareBracket("${rec.s()}", dot), "S");
+            assertErrorContains(withDotOrSquareBracket("${rec.y}", dot), 
"SimpleMethodModel");
+            assertOutput(withDotOrSquareBracket("${rec.y()}", dot), "2");
+            assertErrorContains(withDotOrSquareBracket("${rec.tenX}", dot), 
"SimpleMethodModel");
+            assertOutput(withDotOrSquareBracket("${rec.tenX()}", dot), "10");
+        }
+        assertRecPolicyIndependentMembers();
+    }
+
+    private void assertRecIsPropertyOnly() throws IOException, 
TemplateException {
+        for (boolean dot : List.of(true, false)) {
+            assertOutput(withDotOrSquareBracket("${rec.x}", dot), "1");
+            assertErrorContains(withDotOrSquareBracket("${rec.x()}", dot), 
"SimpleNumber", "must not be called as a method");
+            assertOutput(withDotOrSquareBracket("${rec.s}", dot), "S");
+            assertErrorContains(withDotOrSquareBracket("${rec.s()}", dot), 
"SimpleScalar");
+            assertOutput(withDotOrSquareBracket("${rec.y}", dot), "2");
+            assertErrorContains(withDotOrSquareBracket("${rec.y()}", dot), 
"SimpleNumber");
+            assertOutput(withDotOrSquareBracket("${rec.tenX}", dot), "10");
+            assertErrorContains(withDotOrSquareBracket("${rec.tenX()}", dot), 
"SimpleNumber");
+        }
+        assertRecPolicyIndependentMembers();
+    }
+
+    private void assertRecPolicyIndependentMembers() throws IOException, 
TemplateException {
+        for (boolean dot : List.of(true, false)) {
+            assertOutput(withDotOrSquareBracket("${rec.z}", dot), "3");
+            assertErrorContains(withDotOrSquareBracket("${rec.z()}", dot), 
"SimpleNumber");
+            assertOutput(withDotOrSquareBracket("${rec.getZ()}", dot), "3");
+            assertOutput(withDotOrSquareBracket("${rec.xTimes(5)}", dot), "5");
+            assertErrorContains(withDotOrSquareBracket("${rec.xTimes}", dot), 
"SimpleMethodModel");
+            assertOutput(withDotOrSquareBracket("${rec.voidMethod()}", dot), 
"");
+            assertErrorContains(withDotOrSquareBracket("${rec.voidMethod}", 
dot), "SimpleMethodModel");
+        }
+    }
+
+    private void assertNrcIsMethodOnly() throws IOException, TemplateException 
{
+        for (boolean dot : List.of(true, false)) {
+            assertErrorContains(withDotOrSquareBracket("${nrc.x}", dot), 
"SimpleMethodModel");
+            assertOutput(withDotOrSquareBracket("${nrc.x()}", dot), "1");
+            assertErrorContains(withDotOrSquareBracket("${nrc.y}", dot), 
"SimpleMethodModel");
+            assertOutput(withDotOrSquareBracket("${nrc.y()}", dot), "2");
+            assertErrorContains(withDotOrSquareBracket("${nrc.tenX}", dot), 
"SimpleMethodModel");
+            assertOutput(withDotOrSquareBracket("${nrc.tenX()}", dot), "10");
+        }
+        assertNrcPolicyIndependentMembers();
+    }
+
+    private void assertNrcIsBothPropertyAndMethod() throws IOException, 
TemplateException {
+        for (boolean dot : List.of(true, false)) {
+            assertOutput(withDotOrSquareBracket("${nrc.x}", dot), "1");
+            assertOutput(withDotOrSquareBracket("${nrc.x()}", dot), "1");
+            assertOutput(withDotOrSquareBracket("${nrc.y}", dot), "2");
+            assertOutput(withDotOrSquareBracket("${nrc.y()}", dot), "2");
+            assertOutput(withDotOrSquareBracket("${nrc.tenX}", dot), "10");
+            assertOutput(withDotOrSquareBracket("${nrc.tenX()}", dot), "10");
+        }
+        assertNrcPolicyIndependentMembers();
+    }
+
+    private void assertNrcIsPropertyOnly() throws IOException, 
TemplateException {
+        for (boolean dot : List.of(true, false)) {
+            assertOutput(withDotOrSquareBracket("${nrc.x}", dot), "1");
+            assertErrorContains(withDotOrSquareBracket("${nrc.x()}", dot), 
"SimpleNumber", "must not be called as a method");
+            assertOutput(withDotOrSquareBracket("${nrc.y}", dot), "2");
+            assertErrorContains(withDotOrSquareBracket("${nrc.y()}", dot), 
"SimpleNumber");
+            assertOutput(withDotOrSquareBracket("${nrc.tenX}", dot), "10");
+            assertErrorContains(withDotOrSquareBracket("${nrc.tenX()}", dot), 
"SimpleNumber");
+        }
+        assertNrcPolicyIndependentMembers();
+    }
+
+    private void assertNrcPolicyIndependentMembers() throws IOException, 
TemplateException {
+        for (boolean dot : List.of(true, false)) {
+            assertOutput(withDotOrSquareBracket("${nrc.z}", dot), "3");
+            assertErrorContains(withDotOrSquareBracket("${nrc.z()}", dot), 
"SimpleNumber");
+            assertOutput(withDotOrSquareBracket("${nrc.getZ()}", dot), "3");
+            assertOutput(withDotOrSquareBracket("${nrc.xTimes(5)}", dot), "5");
+            assertErrorContains(withDotOrSquareBracket("${nrc.xTimes}", dot), 
"SimpleMethodModel");
+            assertOutput(withDotOrSquareBracket("${nrc.voidMethod()}", dot), 
"");
+            assertErrorContains(withDotOrSquareBracket("${nrc.voidMethod}", 
dot), "SimpleMethodModel");
+        }
+    }
+
+    public interface TestInterface {
+        int y();
+
+        /**
+         * Defines a real JavaBeans property, "z", so the {@link 
ZeroArgumentNonVoidMethodPolicy} shouldn't affect this
+         */
+        int getZ();
+    }
+
+    /**
+     * Defines record component readers for "x" and "s", and some other 
non-record-component methods that are still
+     * potentially exposed as if there were properties.
+     */
+    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;
+        }
+
+        /**
+         * Has an argument, so this never should be exposed as property.
+         */
+        public int xTimes(int m) {
+            return x * m;
+        }
+
+        /**
+         * Has a void return type, so this never should be exposed as property.
+         */
+        public void voidMethod() {
+            // do nothing
+        }
+    }
+
+    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;
+        }
+
+        /**
+         * Has a void return type, so this never should be exposed as property.
+         */
+        public void voidMethod() {
+            // do nothing
+        }
+    }
+
+}
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();
diff --git a/freemarker-manual/src/main/docgen/en_US/book.xml 
b/freemarker-manual/src/main/docgen/en_US/book.xml
index 06fa9f9e..4b5a0f07 100644
--- a/freemarker-manual/src/main/docgen/en_US/book.xml
+++ b/freemarker-manual/src/main/docgen/en_US/book.xml
@@ -30233,6 +30233,108 @@ TemplateModel x = env.getVariable("x");  // get 
variable x</programlisting>
           <title>Changes on the Java side</title>
 
           <itemizedlist>
+            <listitem>
+              <para><link
+              
xlink:href="https://issues.apache.org/jira/browse/FREEMARKER-183";>FREEMARKER-183</link>:
+              Better support for Java records, if you set the <link
+              
linkend="pgui_config_incompatible_improvements_how_to_set"><literal>incompatible_improvements</literal>
+              setting</link> to 2.3.33 or higher (or if you create your own
+              <literal>ObjectWrapper</literal>, then set its
+              <literal>incompatible_improvements</literal>, or just its
+              <literal>recordZeroArgumentNonVoidMethodPolicy</literal>
+              property to <literal>BOTH_PROPERTY_AND_METHOD</literal>). If in
+              a Java record you have something like <literal>int
+              price()</literal>, earlier you could only read the value in
+              templates as <literal>obj.price()</literal>. With this
+              improvement <literal>obj.price</literal> will do the same (and
+              then <literal>obj["price"]()</literal>, and
+              <literal>obj["price"]</literal> too). This has always worked for
+              JavaBeans properties, like <literal>int getPrice()</literal>
+              could always be used in templates as
+              <literal>obj.price</literal>, in additionally to as
+              <literal>obj.getPrice()</literal>. Now this also works for Java
+              records, and there we simply treat all methods that has 0
+              arguments and non-void return type like a property. Although
+              this is a trickier situation, as the name of the method is the
+              same as the name of our fake property (both are
+              <literal>price</literal>, while with JavaBeans one was
+              <literal>price</literal>, and the other is
+              <literal>getPrice</literal>), so there are some dirty tricks
+              involved, but as far as most uses care, it just works. Some more
+              technical related changes:</para>
+
+              <itemizedlist>
+                <listitem>
+                  <para>Added two new setting to
+                  <literal>BeansWrapper</literal>, and therefore
+                  <literal>DefaultObjectWrapper</literal>:
+                  <literal>recordZeroArgumentNonVoidMethodPolicy</literal>,
+                  and
+                  <literal>nonRecordZeroArgumentNonVoidMethodPolicy</literal>.
+                  Each has enum type
+                  
<literal>freemarker.ext.beans.ZeroArgumentNonVoidMethodPolicy</literal>,
+                  the can be <literal>METHOD_ONLY</literal>,
+                  <literal>PROPERTY_ONLY</literal>, or
+                  <literal>BOTH_PROPERTY_AND_METHOD</literal>.
+                  Therefore:</para>
+
+                  <itemizedlist>
+                    <listitem>
+                      <para>Note that with
+                      
<literal>nonRecordZeroArgumentNonVoidMethodPolicy</literal>
+                      you can set similar behavior to non-records. That is,
+                      you can call 0 argument non-void methods without
+                      <literal>()</literal>, if you want. It's only meant to
+                      be used for methods that are mere value readers, and has
+                      no side effect.</para>
+                    </listitem>
+
+                    <listitem>
+                      <para>For records, you can enforce proper style with
+                      setting
+                      <literal>recordZeroArgumentNonVoidMethodPolicy</literal>
+                      to <literal>PROPERTY_ONLY</literal>. The default is more
+                      lenient (as there using <literal>()</literal> is
+                      allowed) for backward compatibility (and because people
+                      often just use the Java syntax).</para>
+                    </listitem>
+                  </itemizedlist>
+                </listitem>
+
+                <listitem>
+                  <para>Added new interface,
+                  <literal>MethodCallAwareTemplateHashModel</literal>, which
+                  adds <literal>getBeforeMethodCall(String key)</literal>. If
+                  you have something like <literal>obj.price()</literal>, the
+                  that's called instead of
+                  <literal>TemplateHashModel.get("price")</literal>, in a
+                  template. This is needed if for
+                  
<literal>ZeroArgumentNonVoidMethodPolicy.BOTH_PROPERTY_AND_METHOD</literal>
+                  to work as expected.</para>
+                </listitem>
+
+                <listitem>
+                  <para>Added <literal>GenericObjectModel</literal>, which
+                  extends <literal>StringModel</literal> with implementing
+                  <literal>MethodCallAwareTemplateHashModel</literal>, and has
+                  a more telling name. <literal>BeansWrapper</literal>, and
+                  therefore <literal>DefaultObjectWrapper</literal> now
+                  creates <literal>GenericObjectModel</literal>-s instead of
+                  <literal>StringModel</literal>-s. This is like so regardless
+                  of any setting, like regardless of
+                  <literal>incompatible_improvements</literal>.</para>
+                </listitem>
+
+                <listitem>
+                  <para>You shouldn't override
+                  <literal>BeanModel.get(String)</literal> anymore, but
+                  <literal>BeanModel.get(String, boolean)</literal>. If you
+                  have overridden <literal>get</literal>, then see in the
+                  Javadoc for more.</para>
+                </listitem>
+              </itemizedlist>
+            </listitem>
+
             <listitem>
               <para><link
               xlink:href="https://github.com/apache/freemarker/pull/88";>GitHub
diff --git 
a/freemarker-test-utils/src/main/java/freemarker/test/TemplateTest.java 
b/freemarker-test-utils/src/main/java/freemarker/test/TemplateTest.java
index a730b3e4..1ab7af45 100644
--- a/freemarker-test-utils/src/main/java/freemarker/test/TemplateTest.java
+++ b/freemarker-test-utils/src/main/java/freemarker/test/TemplateTest.java
@@ -42,6 +42,7 @@ import freemarker.cache.StringTemplateLoader;
 import freemarker.cache.TemplateLoader;
 import freemarker.core.ParseException;
 import freemarker.template.Configuration;
+import freemarker.template.SimpleHash;
 import freemarker.template.Template;
 import freemarker.template.TemplateException;
 import freemarker.template.utility.StringUtil;
@@ -146,7 +147,8 @@ public abstract class TemplateTest {
     
     protected String getOutput(Template t) throws TemplateException, 
IOException {
         StringWriter out = new StringWriter();
-        t.process(getDataModel(), new FilterWriter(out) {
+        Object dataModelObject = getDataModel();
+        t.process(dataModelObject, new FilterWriter(out) {
             private boolean closed;
 
             @Override
@@ -197,6 +199,11 @@ public abstract class TemplateTest {
         }
         return dataModel;
     }
+
+    protected void setDataModel(Object dataModel) {
+        this.dataModel = dataModel;
+        dataModelCreated = true;
+    }
     
     protected Object createDataModel() {
         return null;
@@ -248,6 +255,7 @@ public abstract class TemplateTest {
         }
     }
 
+    @SuppressWarnings({"unchecked", "rawtypes"})
     protected void addToDataModel(String name, Object value) {
         Object dm = getDataModel();
         if (dm == null) {
@@ -256,6 +264,9 @@ public abstract class TemplateTest {
         }
         if (dm instanceof Map) {
             ((Map) dm).put(name, value);
+        } else if (dm instanceof SimpleHash) {
+            // SimpleHash is interesting, as it caches the top-level 
TemplateDateModel-s
+            ((SimpleHash) dm).put(name, value);
         } else {
             throw new IllegalStateException("Can't add to non-Map data-model: 
" + dm);
         }
@@ -289,7 +300,7 @@ public abstract class TemplateTest {
                 t = new Template("adhoc", ftl, getConfiguration());
             }
             t.process(getDataModel(), new StringWriter());
-            fail("The tempalte had to fail");
+            fail("The template had to fail");
             return null;
         } catch (TemplateException e) {
             if (exceptionClass != null) {

Reply via email to