You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@freemarker.apache.org by dd...@apache.org on 2024/02/13 08:15:30 UTC

(freemarker) branch FREEMARKER-183 updated (fc8a597c -> 4c760f6e)

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

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


 discard fc8a597c 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 MethodCallAwareTe [...]
     add ef28b9ce Manual: Old URL-s cleanup
     new 4c760f6e 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 MethodCallAwareTe [...]

This update added new revisions after undoing existing revisions.
That is to say, some revisions that were in the old version of the
branch are not in the new version.  This situation occurs
when a user --force pushes a change and generates a repository
containing something like this:

 * -- * -- B -- O -- O -- O   (fc8a597c)
            \
             N -- N -- N   refs/heads/FREEMARKER-183 (4c760f6e)

You should already have received notification emails for all of the O
revisions, and so the following emails describe only the N revisions
from the common base, B.

Any revisions marked "omit" are not gone; other references still
refer to them.  Any revisions marked "discard" are gone forever.

The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.


Summary of changes:
 freemarker-manual/src/main/docgen/en_US/docgen.cjson | 5 ++---
 1 file changed, 2 insertions(+), 3 deletions(-)


(freemarker) 01/01: 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 MethodCallAwareTemplateHashModel, an [...]

Posted by dd...@apache.org.
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 4c760f6e2283304d47e1395472f74772f60cfd5a
Author: ddekany <dd...@apache.org>
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   |  13 +-
 .../main/java/freemarker/ext/beans/BeanModel.java  | 108 ++++++-
 .../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   |  72 +++++
 .../ext/beans/MethodAppearanceFineTuner.java       |  20 +-
 .../java/freemarker/ext/beans/StringModel.java     |  15 +-
 .../ext/beans/ZeroArgumentNonVoidMethodPolicy.java |  65 ++++
 .../main/java/freemarker/ext/beans/_BeansAPI.java  |   4 +-
 .../template/MethodCallAwareTemplateHashModel.java | 124 ++++++++
 .../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   | 130 +++++++-
 .../main/java/freemarker/test/TemplateTest.java    |  15 +-
 26 files changed, 1342 insertions(+), 74 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..c842c7d6
--- /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.template.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..f9678cac
--- /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.template.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..2bc875e2 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,10 @@
 
 package freemarker.ext.beans;
 
+import freemarker.template.MethodCallAwareTemplateHashModel;
+import freemarker.template.TemplateModel;
+import freemarker.template.TemplateModelException;
+
 /**
  * Exposes the Java API (and properties) of an object.
  * 
@@ -32,7 +36,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 +45,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..fd92bab8 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;
@@ -38,16 +40,20 @@ import freemarker.ext.util.ModelFactory;
 import freemarker.ext.util.WrapperTemplateModel;
 import freemarker.log.Logger;
 import freemarker.template.AdapterTemplateModel;
+import freemarker.template.MethodCallAwareTemplateHashModel;
 import freemarker.template.ObjectWrapper;
 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 +140,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 +209,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 +221,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 +239,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 +267,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 +280,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 +297,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..bb1723f2
--- /dev/null
+++ b/freemarker-core/src/main/java/freemarker/ext/beans/GenericObjectModel.java
@@ -0,0 +1,72 @@
+/*
+ * 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.MethodCallAwareTemplateHashModel;
+import freemarker.template.TemplateModel;
+import freemarker.template.TemplateModelException;
+
+/**
+ * This is used for wrapping objects that has no special treatment (unlike {@link Map}-s, {@link Collection}-s,
+ * {@link Number}-s, {@link Boolean}-s, and some more, which 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/StringModel.java b/freemarker-core/src/main/java/freemarker/ext/beans/StringModel.java
index b53872d0..7936fcab 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,20 @@
 package freemarker.ext.beans;
 
 import freemarker.ext.util.ModelFactory;
-import freemarker.template.ObjectWrapper;
-import freemarker.template.TemplateModel;
+import freemarker.template.MethodCallAwareTemplateHashModel;
 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/java/freemarker/template/MethodCallAwareTemplateHashModel.java b/freemarker-core/src/main/java/freemarker/template/MethodCallAwareTemplateHashModel.java
new file mode 100644
index 00000000..6c4912e2
--- /dev/null
+++ b/freemarker-core/src/main/java/freemarker/template/MethodCallAwareTemplateHashModel.java
@@ -0,0 +1,124 @@
+/*
+ * 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.template;
+
+import java.util.Collection;
+import java.util.Map;
+
+import freemarker.core.Macro;
+import freemarker.core.NonMethodException;
+import freemarker.ext.beans.BeansWrapper;
+import freemarker.ext.beans.ZeroArgumentNonVoidMethodPolicy;
+import freemarker.template.utility.NullArgumentException;
+
+/**
+ * Adds a getter method to {@link TemplateHashModel}, 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 subtle features like
+ * {@link BeansWrapper.MethodAppearanceDecision#setMethodInsteadOfPropertyValueBeforeCall(boolean)},
+ * 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 (see that at
+ * {@link BeansWrapper.MethodAppearanceDecision#setMethodInsteadOfPropertyValueBeforeCall(boolean)}).
+ *
+ * <p>Objects wrapped with {@link BeansWrapper}, and hence with {@link DefaultObjectWrapper}, will implement this
+ * interface, when they are "generic" objects (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/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..56750583 100644
--- a/freemarker-manual/src/main/docgen/en_US/book.xml
+++ b/freemarker-manual/src/main/docgen/en_US/book.xml
@@ -20,7 +20,10 @@
 <book conformance="docgen" version="5.0" xml:lang="en"
       xmlns="http://docbook.org/ns/docbook"
       xmlns:xlink="http://www.w3.org/1999/xlink"
->
+      xmlns:ns5="http://www.w3.org/1999/xhtml"
+      xmlns:ns4="http://www.w3.org/2000/svg"
+      xmlns:ns3="http://www.w3.org/1998/Math/MathML"
+      xmlns:ns="http://docbook.org/ns/docbook">
   <info>
     <title>Apache FreeMarker Manual</title>
 
@@ -30126,6 +30129,18 @@ TemplateModel x = env.getVariable("x");  // get variable x</programlisting>
           <title>Changes on the FTL side</title>
 
           <itemizedlist>
+            <listitem>
+              <para><link
+              xlink:href="https://issues.apache.org/jira/browse/FREEMARKER-183">FREEMARKER-183</link>:
+              If FreeMarker is configured like so, values in Java records can
+              now be referred like <literal>obj.price</literal>, instead of
+              like <literal>obj.price()</literal>. Furthermore, FreeMarker can
+              now be configured to allow this for all 0-argument
+              non-<literal>void</literal> methods. See more details in the
+              <link linkend="version_hisotry_freemarker_183_java_side">Changes
+              on the Java side</link> section below.</para>
+            </listitem>
+
             <listitem>
               <para><link
               xlink:href="https://github.com/apache/freemarker/pull/87">GitHub
@@ -30229,10 +30244,121 @@ TemplateModel x = env.getVariable("x");  // get variable x</programlisting>
           </itemizedlist>
         </section>
 
-        <section>
+        <section xml:id="version_hisotry_freemarker_183_java_side">
           <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
+              similarly, <literal>obj["price"]()</literal>, and
+              <literal>obj["price"]</literal> will do the same). 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, as there we simply treat all methods that has 0
+              arguments, and non-<literal>void</literal> return type as if it
+              was a JavaBean property read method. Except, here the name of
+              the method is exactly the same as the name of the faked
+              JavaBeans property (<literal>price</literal>), while with real
+              JavaBeans the read method name typically would be
+              <literal>getPrice</literal>, and the property name would be
+              <literal>price</literal> (so we have two separate names). There
+              are some strange technical tricks involved for the same name to
+              be usable in both ways, but as far as most users care, it just
+              works.</para>
+
+              <para>Some more technical changes:</para>
+
+              <itemizedlist>
+                <listitem>
+                  <para>Added two new settings 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>,
+                  that 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 with
+                      <literal>incompatible_improvements</literal> 2.3.33 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>freemarker.template.MethodCallAwareTemplateHashModel</literal>,
+                  which adds <literal>getBeforeMethodCall(String
+                  key)</literal>. If you have something like
+                  <literal>obj.price()</literal> in a template, where
+                  <literal>obj</literal> (after wrapping) implements that
+                  interface, then
+                  <literal>getBeforeMethodCall("price")</literal> called
+                  instead of
+                  <literal>TemplateHashModel.get("price")</literal>. This is
+                  needed for
+                  <literal>ZeroArgumentNonVoidMethodPolicy.BOTH_PROPERTY_AND_METHOD</literal>
+                  to work.</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) {