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 2019/05/25 23:02:15 UTC

[freemarker] 01/03: exp[rangeExp] operator now supports lazily generated sequences as input. For now it always gives lazily generated output in that case, but that's incorrect and will change.

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

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

commit 33d6e9a7feaa615f1cb7f19365c0c715e77cdaa3
Author: ddekany <dd...@apache.org>
AuthorDate: Sat Apr 6 12:37:20 2019 +0200

    exp[rangeExp] operator now supports lazily generated sequences as input. For now it always gives lazily generated output in that case, but that's incorrect and will change.
---
 .../java/freemarker/core/BoundedRangeModel.java    |   2 +-
 src/main/java/freemarker/core/DynamicKeyName.java  | 213 +++++++++++++++++----
 src/main/java/freemarker/core/RangeModel.java      |   2 +-
 .../freemarker/core/RightUnboundedRangeModel.java  |   2 +-
 src/main/java/freemarker/core/_CoreAPI.java        |   5 +-
 .../freemarker/template/utility/ClassUtil.java     |   3 +-
 src/main/resources/freemarker/included.html        |   9 +
 ...iltinsTest.java => LazilyGeneratedSeqTest.java} |  26 ++-
 .../test/templatesuite/templates/range-lazy.ftl    |  58 ++++++
 .../freemarker/test/templatesuite/testcases.xml    |   5 +-
 10 files changed, 285 insertions(+), 40 deletions(-)

diff --git a/src/main/java/freemarker/core/BoundedRangeModel.java b/src/main/java/freemarker/core/BoundedRangeModel.java
index 4240e89..84e2f28 100644
--- a/src/main/java/freemarker/core/BoundedRangeModel.java
+++ b/src/main/java/freemarker/core/BoundedRangeModel.java
@@ -62,7 +62,7 @@ final class BoundedRangeModel extends RangeModel {
     }
 
     @Override
-    boolean isAffactedByStringSlicingBug() {
+    boolean isAffectedByStringSlicingBug() {
         return affectedByStringSlicingBug;
     }
     
diff --git a/src/main/java/freemarker/core/DynamicKeyName.java b/src/main/java/freemarker/core/DynamicKeyName.java
index d369e8e..c01d0ef 100644
--- a/src/main/java/freemarker/core/DynamicKeyName.java
+++ b/src/main/java/freemarker/core/DynamicKeyName.java
@@ -24,9 +24,12 @@ import java.util.Collections;
 
 import freemarker.template.SimpleScalar;
 import freemarker.template.SimpleSequence;
+import freemarker.template.TemplateCollectionModelEx;
 import freemarker.template.TemplateException;
 import freemarker.template.TemplateHashModel;
 import freemarker.template.TemplateModel;
+import freemarker.template.TemplateModelException;
+import freemarker.template.TemplateModelIterator;
 import freemarker.template.TemplateNumberModel;
 import freemarker.template.TemplateScalarModel;
 import freemarker.template.TemplateSequenceModel;
@@ -39,12 +42,20 @@ import freemarker.template.utility.Constants;
  */
 final class DynamicKeyName extends Expression {
 
+    private static final int UNKNOWN_RESULT_SIZE = -1;
+
     private final Expression keyExpression;
     private final Expression target;
 
     DynamicKeyName(Expression target, Expression keyExpression) {
         this.target = target; 
         this.keyExpression = keyExpression;
+
+        Expression cleanedTarget = MiscUtil.peelParentheses(target);
+        if (cleanedTarget instanceof BuiltInsForSequences.IntermediateStreamOperationLikeBuiltIn) {
+            ((BuiltInsForSequences.IntermediateStreamOperationLikeBuiltIn) cleanedTarget)
+                    .setLazyResultGenerationAllowed(true);
+        }
     }
 
     @Override
@@ -103,8 +114,19 @@ final class DynamicKeyName extends Expression {
                 size = Integer.MAX_VALUE;
             }
             return index < size ? tsm.get(index) : null;
-        } 
-        
+        }
+        if (targetModel instanceof LazilyGeneratedSequenceModel) {
+            TemplateModelIterator iter = ((LazilyGeneratedSequenceModel) targetModel).iterator();
+            for (int curIndex = 0; iter.hasNext(); curIndex++) {
+                TemplateModel next = iter.next();
+                if (index == curIndex) {
+                    return next;
+                }
+            }
+            return null;
+        }
+
+        // Fall back to get a character from a string
         try {
             String s = target.evalAndCoerceToPlainText(env);
             try {
@@ -143,14 +165,22 @@ final class DynamicKeyName extends Expression {
     }
 
     private TemplateModel dealWithRangeKey(TemplateModel targetModel, RangeModel range, Environment env)
-    throws UnexpectedTypeException, InvalidReferenceException, TemplateException {
+    throws TemplateException {
+        // We can have 3 kind of left hand operands ("targets"): sequence, lazyily generated sequence, string
         final TemplateSequenceModel targetSeq;
+        final LazilyGeneratedSequenceModel targetLazySeq;
         final String targetStr;
         if (targetModel instanceof TemplateSequenceModel) {
             targetSeq = (TemplateSequenceModel) targetModel;
+            targetLazySeq = null;
+            targetStr = null;
+        } else if (targetModel instanceof LazilyGeneratedSequenceModel) {
+            targetSeq = null;
+            targetLazySeq = (LazilyGeneratedSequenceModel) targetModel;
             targetStr = null;
         } else {
             targetSeq = null;
+            targetLazySeq = null;
             try {
                 targetStr = target.evalAndCoerceToPlainText(env);
             } catch (NonStringException e) {
@@ -161,13 +191,13 @@ final class DynamicKeyName extends Expression {
             }
         }
         
-        final int size = range.size();
+        final int rangeSize = range.size(); // Warning: Value is meaningless for right unbounded sequences
         final boolean rightUnbounded = range.isRightUnbounded();
-        final boolean rightAdaptive = range.isRightAdaptive();
+        final boolean rightAdaptive = range.isRightAdaptive(); // Always true if rightUnbounded
         
-        // Right bounded empty ranges are accepted even if the begin index is out of bounds. That's because a such range
+        // Empty ranges are accepted even if the begin index is out of bounds. That's because a such range
         // produces an empty sequence, which thus doesn't contain any illegal indexes.
-        if (!rightUnbounded && size == 0) {
+        if (!rightUnbounded && rangeSize == 0) {
             return emptyResult(targetSeq != null);
         }
 
@@ -177,26 +207,45 @@ final class DynamicKeyName extends Expression {
                     "Negative range start index (", Integer.valueOf(firstIdx),
                     ") isn't allowed for a range used for slicing.");
         }
-        
-        final int targetSize = targetStr != null ? targetStr.length() : targetSeq.size();
-        final int step = range.getStep();
-        
-        // Right-adaptive increasing ranges can start 1 after the last element of the target, because they are like
-        // ranges with exclusive end index of at most targetSize. Thence a such range is just an empty list of indexes,
-        // and thus it isn't out-of-bounds.
-        // Right-adaptive decreasing ranges has exclusive end -1, so it can't help on a  to high firstIndex. 
-        // Right-bounded ranges at this point aren't empty, so the right index surely can't reach targetSize. 
-        if (rightAdaptive && step == 1 ? firstIdx > targetSize : firstIdx >= targetSize) {
-            throw new _MiscTemplateException(keyExpression,
-                    "Range start index ", Integer.valueOf(firstIdx), " is out of bounds, because the sliced ",
-                    (targetStr != null ? "string" : "sequence"),
-                    " has only ", Integer.valueOf(targetSize), " ", (targetStr != null ? "character(s)" : "element(s)"),
-                    ". ", "(Note that indices are 0-based).");
+
+        final int step = range.getStep(); // Currently always 1 or -1
+
+        final int targetSize;
+        final boolean targetSizeKnown; // Didn't want to use targetSize = -1, as we don't control the seq.size() impl.
+        if (targetStr != null) {
+            targetSize = targetStr.length();
+            targetSizeKnown = true;
+        } else if (targetSeq != null) {
+            targetSize = targetSeq.size();
+            targetSizeKnown = true;
+        } else if (targetLazySeq instanceof TemplateCollectionModelEx) {
+            // E.g. the size of seq?map(f) is known, despite that the elements are lazily calculated.
+            targetSize = ((TemplateCollectionModelEx) targetLazySeq).size();
+            targetSizeKnown = true;
+        } else {
+            targetSize = Integer.MAX_VALUE;
+            targetSizeKnown = false;
+        }
+
+        if (targetSizeKnown) {
+            // Right-adaptive increasing ranges can start 1 after the last element of the target, because they are like
+            // ranges with exclusive end index of at most targetSize. Hence a such range is just an empty list of
+            // indexes, and thus it isn't out-of-bounds.
+            // Right-adaptive decreasing ranges has exclusive end -1, so it can't help on a too high firstIndex.
+            // Right-bounded ranges at this point aren't empty, so firstIndex == targetSize can't be allowed.
+            if (rightAdaptive && step == 1 ? firstIdx > targetSize : firstIdx >= targetSize) {
+                throw new _MiscTemplateException(keyExpression,
+                        "Range start index ", Integer.valueOf(firstIdx), " is out of bounds, because the sliced ",
+                        (targetStr != null ? "string" : "sequence"),
+                        " has only ", Integer.valueOf(targetSize), " ",
+                        (targetStr != null ? "character(s)" : "element(s)"),
+                        ". ", "(Note that indices are 0-based).");
+            }
         }
         
-        final int resultSize;
+        final int resultSize; // Might will be UNKNOWN_RESULT_SIZE, when targetLazySeq != null
         if (!rightUnbounded) {
-            final int lastIdx = firstIdx + (size - 1) * step;
+            final int lastIdx = firstIdx + (rangeSize - 1) * step; // Note: lastIdx is inclusive
             if (lastIdx < 0) {
                 if (!rightAdaptive) {
                     throw new _MiscTemplateException(keyExpression,
@@ -205,7 +254,7 @@ final class DynamicKeyName extends Expression {
                 } else {
                     resultSize = firstIdx + 1;
                 }
-            } else if (lastIdx >= targetSize) {
+            } else if (targetSizeKnown && lastIdx >= targetSize) {
                 if (!rightAdaptive) {
                     throw new _MiscTemplateException(keyExpression,
                             "Range end index ", Integer.valueOf(lastIdx), " is out of bounds, because the sliced ",
@@ -216,28 +265,36 @@ final class DynamicKeyName extends Expression {
                     resultSize = Math.abs(targetSize - firstIdx);
                 }
             } else {
-                resultSize = size;
+                resultSize = rangeSize;
             }
-        } else {
-            resultSize = targetSize - firstIdx;
+        } else { // rightUnbounded
+            resultSize = targetSizeKnown ? targetSize - firstIdx : UNKNOWN_RESULT_SIZE;
         }
         
         if (resultSize == 0) {
             return emptyResult(targetSeq != null);
         }
         if (targetSeq != null) {
-            ArrayList/*<TemplateModel>*/ list = new ArrayList(resultSize);
+            ArrayList<TemplateModel> resultList = new ArrayList<TemplateModel>(resultSize);
             int srcIdx = firstIdx;
             for (int i = 0; i < resultSize; i++) {
-                list.add(targetSeq.get(srcIdx));
+                resultList.add(targetSeq.get(srcIdx));
                 srcIdx += step;
             }
             // List items are already wrapped, so the wrapper will be null:
-            return new SimpleSequence(list, null);
+            return new SimpleSequence(resultList, null);
+        } else if (targetLazySeq != null) {
+            if (step == 1) {
+                return getStep1RangeFromIterator(targetLazySeq.iterator(), range, resultSize);
+            } else if (step == -1) {
+                return getStepMinus1RangeFromIterator(targetLazySeq.iterator(), range, resultSize);
+            } else {
+                throw new AssertionError();
+            }
         } else {
             final int exclEndIdx;
             if (step < 0 && resultSize > 1) {
-                if (!(range.isAffactedByStringSlicingBug() && resultSize == 2)) {
+                if (!(range.isAffectedByStringSlicingBug() && resultSize == 2)) {
                     throw new _MiscTemplateException(keyExpression,
                             "Decreasing ranges aren't allowed for slicing strings (as it would give reversed text). "
                             + "The index range was: first = ", Integer.valueOf(firstIdx),
@@ -255,6 +312,98 @@ final class DynamicKeyName extends Expression {
         }
     }
 
+    private TemplateModel getStep1RangeFromIterator(final TemplateModelIterator targetIter, final RangeModel range, int resultSize)
+            throws TemplateModelException {
+        final int firstIdx = range.getBegining();
+        final int lastIdx = firstIdx + (range.size() - 1); // Note: meaningless if the range is right unbounded
+        final boolean rightAdaptive = range.isRightAdaptive();
+        final boolean rightUnbounded = range.isRightUnbounded();
+        return new LazilyGeneratedSequenceModel(new TemplateModelIterator() {
+            private boolean elementsBeforeFirsIndexWereSkipped;
+            private int nextIdx;
+
+            public TemplateModel next() throws TemplateModelException {
+                ensureElementsBeforeFirstIndexWereSkipped();
+                if (!rightUnbounded && nextIdx > lastIdx) {
+                    throw new _TemplateModelException(
+                            "Iterator has no more elements (at index ", Integer.valueOf(nextIdx), ")");
+                }
+                if (!rightAdaptive && !targetIter.hasNext()) {
+                    // We fail because the range was wrong, not because this iterator was over-consumed.
+                    throw new _TemplateModelException(keyExpression,
+                            "Range end index ", Integer.valueOf(lastIdx), " is out of bounds, as sliced sequence " +
+                            "only has ", nextIdx, " elements.");
+                }
+                TemplateModel result = targetIter.next();
+                nextIdx++;
+                return result;
+            }
+
+            public boolean hasNext() throws TemplateModelException {
+                ensureElementsBeforeFirstIndexWereSkipped();
+                return (rightUnbounded || nextIdx <= lastIdx) && (!rightAdaptive || targetIter.hasNext());
+            }
+
+            public void ensureElementsBeforeFirstIndexWereSkipped() throws TemplateModelException {
+                if (elementsBeforeFirsIndexWereSkipped) {
+                    return;
+                }
+
+                while (nextIdx < firstIdx) {
+                    if (!targetIter.hasNext()) {
+                        throw new _TemplateModelException(keyExpression,
+                                "Range start index ", Integer.valueOf(firstIdx), " is out of bounds, as the sliced " +
+                                "sequence only has ", nextIdx, " elements.");
+                    }
+                    targetIter.next();
+                    nextIdx++;
+                }
+                elementsBeforeFirsIndexWereSkipped = true;
+            }
+        });
+    }
+
+    // Because the order has to be reversed, we have to "buffer" the stream in this case.
+    private TemplateModel getStepMinus1RangeFromIterator(TemplateModelIterator targetIter, RangeModel range,
+            int resultSize)
+            throws TemplateException {
+        int highIndex = range.getBegining();
+        // Low index was found to be valid earlier. So now something like [2..*-9] becomes to [2..0] in effect.
+        int lowIndex = Math.max(highIndex - (range.size() - 1), 0);
+
+        final TemplateModel[] resultElements = new TemplateModel[highIndex - lowIndex + 1];
+
+        int srcIdx = 0; // With an Iterator we can only start from index 0
+        int dstIdx = resultElements.length - 1; // Write into the result array backwards
+        while (srcIdx <= highIndex && targetIter.hasNext()) {
+            TemplateModel element = targetIter.next();
+            if (srcIdx >= lowIndex) {
+                resultElements[dstIdx--] = element;
+            }
+            srcIdx++;
+        }
+        if (dstIdx != -1) {
+            throw new _MiscTemplateException(DynamicKeyName.this,
+                    "Range top index " + highIndex + " (0-based) is outside the sliced sequence of length " +
+                    srcIdx + ".");
+        }
+        return new LazilyGeneratedSequenceModel(new TemplateModelIterator() {
+            private int nextIndex;
+
+            public TemplateModel next() throws TemplateModelException {
+                try {
+                    return resultElements[nextIndex++];
+                } catch (IndexOutOfBoundsException e) {
+                    throw new TemplateModelException("There are no more elements in the iterator");
+                }
+            }
+
+            public boolean hasNext() throws TemplateModelException {
+                return nextIndex < resultElements.length;
+            }
+        });
+    }
+
     private TemplateModel emptyResult(boolean seq) {
         return seq
                 ? (_TemplateAPI.getTemplateLanguageVersionAsInt(this) < _TemplateAPI.VERSION_INT_2_3_21
diff --git a/src/main/java/freemarker/core/RangeModel.java b/src/main/java/freemarker/core/RangeModel.java
index 7e526e0..e4f55a2 100644
--- a/src/main/java/freemarker/core/RangeModel.java
+++ b/src/main/java/freemarker/core/RangeModel.java
@@ -53,6 +53,6 @@ abstract class RangeModel implements TemplateSequenceModel, java.io.Serializable
     
     abstract boolean isRightAdaptive();
     
-    abstract boolean isAffactedByStringSlicingBug();
+    abstract boolean isAffectedByStringSlicingBug();
 
 }
diff --git a/src/main/java/freemarker/core/RightUnboundedRangeModel.java b/src/main/java/freemarker/core/RightUnboundedRangeModel.java
index fb1559d..2349d53 100644
--- a/src/main/java/freemarker/core/RightUnboundedRangeModel.java
+++ b/src/main/java/freemarker/core/RightUnboundedRangeModel.java
@@ -41,7 +41,7 @@ abstract class RightUnboundedRangeModel extends RangeModel {
     }
 
     @Override
-    final boolean isAffactedByStringSlicingBug() {
+    final boolean isAffectedByStringSlicingBug() {
         return false;
     }
     
diff --git a/src/main/java/freemarker/core/_CoreAPI.java b/src/main/java/freemarker/core/_CoreAPI.java
index 3491568..7b0596b 100644
--- a/src/main/java/freemarker/core/_CoreAPI.java
+++ b/src/main/java/freemarker/core/_CoreAPI.java
@@ -218,5 +218,8 @@ public class _CoreAPI {
     public static void setPreventStrippings(FMParser parser, boolean preventStrippings) {
         parser.setPreventStrippings(preventStrippings);
     }
-    
+
+    public static boolean isLazilyGeneratedSequenceModel(Class cl) {
+        return LazilyGeneratedSequenceModel.class.isAssignableFrom(cl);
+    }
 }
diff --git a/src/main/java/freemarker/template/utility/ClassUtil.java b/src/main/java/freemarker/template/utility/ClassUtil.java
index 66944fe..45508df 100644
--- a/src/main/java/freemarker/template/utility/ClassUtil.java
+++ b/src/main/java/freemarker/template/utility/ClassUtil.java
@@ -29,6 +29,7 @@ import java.util.Set;
 import freemarker.core.Environment;
 import freemarker.core.Macro;
 import freemarker.core.TemplateMarkupOutputModel;
+import freemarker.core._CoreAPI;
 import freemarker.ext.beans.BeanModel;
 import freemarker.ext.beans.BooleanModel;
 import freemarker.ext.beans.CollectionModel;
@@ -207,7 +208,7 @@ public class ClassUtil {
             appendTypeName(sb, typeNamesAppended, "transform");
         }
         
-        if (TemplateSequenceModel.class.isAssignableFrom(cl)) {
+        if (TemplateSequenceModel.class.isAssignableFrom(cl) || _CoreAPI.isLazilyGeneratedSequenceModel(cl)) {
             appendTypeName(sb, typeNamesAppended, "sequence");
         } else if (TemplateCollectionModel.class.isAssignableFrom(cl)) {
             appendTypeName(sb, typeNamesAppended,
diff --git a/src/main/resources/freemarker/included.html b/src/main/resources/freemarker/included.html
new file mode 100644
index 0000000..36c9254
--- /dev/null
+++ b/src/main/resources/freemarker/included.html
@@ -0,0 +1,9 @@
+<div class="code">
+<pre><code>SCRIPT_DIR=&quot;\
+  $(\
+    cd &quot;$(dirname &quot;${BASH_SRC_DIR[0]}&quot;)&quot; \
+    &gt;/dev/null 2&gt;&amp;1 \
+    &amp;&amp; pwd\
+  )&quot;
+SCRIPT_NAME=$(basename $0)</code></pre>
+</div>
\ No newline at end of file
diff --git a/src/test/java/freemarker/core/LazilyGeneratedSeqTargetSupportInBuiltinsTest.java b/src/test/java/freemarker/core/LazilyGeneratedSeqTest.java
similarity index 87%
rename from src/test/java/freemarker/core/LazilyGeneratedSeqTargetSupportInBuiltinsTest.java
rename to src/test/java/freemarker/core/LazilyGeneratedSeqTest.java
index 36af95f..b4618aa 100644
--- a/src/test/java/freemarker/core/LazilyGeneratedSeqTargetSupportInBuiltinsTest.java
+++ b/src/test/java/freemarker/core/LazilyGeneratedSeqTest.java
@@ -38,9 +38,31 @@ import freemarker.template.TemplateSequenceModel;
 import freemarker.test.TemplateTest;
 
 /**
- * Tests built-ins that are support getting {@link LazilyGeneratedSequenceModel} as their LHO input.
+ * Tests operators and built-ins that are support getting {@link LazilyGeneratedSequenceModel} as their operands.
  */
-public class LazilyGeneratedSeqTargetSupportInBuiltinsTest extends TemplateTest {
+public class LazilyGeneratedSeqTest extends TemplateTest {
+
+    @Test
+    public void dynamicIndexTest() throws Exception {
+        assertErrorContains("${coll?map(it -> it)['x']}",
+                "hash", "evaluated to a sequence");
+
+        assertOutput("${coll?map(it -> it)[0]}",
+                "[iterator][hasNext][next]1");
+        assertOutput("${coll?map(it -> it)[1]}",
+                "[iterator][hasNext][next][hasNext][next]2");
+        assertOutput("${coll?map(it -> it)[2]}",
+                "[iterator][hasNext][next][hasNext][next][hasNext][next]3");
+        assertOutput("${coll?map(it -> it)[3]!'missing'}",
+                "[iterator][hasNext][next][hasNext][next][hasNext][next][hasNext]missing");
+        assertOutput("${coll?filter(it -> it % 2 == 0)[0]}",
+                "[iterator][hasNext][next][hasNext][next]2");
+        assertOutput("${coll?filter(it -> it > 3)[0]!'missing'}",
+                "[iterator][hasNext][next][hasNext][next][hasNext][next][hasNext]missing");
+
+        assertOutput("${collLong?map(it -> it)[1 .. 2]?join(', ')}",
+                "[iterator][hasNext][next][hasNext][next][hasNext][next]2, 3");
+    }
 
     @Test
     public void sizeBasicsTest() throws Exception {
diff --git a/src/test/resources/freemarker/test/templatesuite/templates/range-lazy.ftl b/src/test/resources/freemarker/test/templatesuite/templates/range-lazy.ftl
new file mode 100644
index 0000000..4fe96e1
--- /dev/null
+++ b/src/test/resources/freemarker/test/templatesuite/templates/range-lazy.ftl
@@ -0,0 +1,58 @@
+<#assign s="012">
+<#assign seq=[0, 1, 2]>
+
+<#list ['s', 'seq', 'seq?map(it -> it)', 'seq?filter(it -> true)'] as sliced>
+  <#-- Copy loop var to namespace var: -->
+  <#assign sliced = sliced>
+
+  <@assertSliceEquals '2', '2..' />
+  <@assertSliceFails '2..3' />
+  <@assertSliceEquals '2', '2..!3' />
+
+  <@assertSliceEquals '', '3..' />
+  <@assertSliceEquals '', '3..*1' />
+  <@assertSliceFails '3..*-1' />
+  <@assertSliceFails '3..3' />
+
+  <@assertSliceFails '4..' />
+  <@assertSliceFails '4..*1' />
+  <@assertSliceFails '4..*-1' />
+
+  <@assertSliceEquals '', '1..*0' />
+  <@assertSliceEquals '', '3..*0' />
+  <@assertSliceEquals '', '4..*0' />
+
+  <@assertSliceEquals '0', '0..*-1' />
+  <@assertSliceEquals '0', '0..*-2' />
+  <@assertSliceEquals '1', '1..*-1' />
+  <@assertSliceEquals '2', '2..*-1' />
+  <#if sliced != 's'>
+    <@assertSliceEquals '10', '1..*-2' />
+    <@assertSliceEquals '10', '1..*-3' />
+    <@assertSliceEquals '21', '2..*-2' />
+    <@assertSliceEquals '210', '2..*-3' />
+  <#else>
+    <@assertSliceFails '1..*-2' />
+    <@assertSliceFails '1..*-3' />
+    <@assertSliceFails '2..*-2' />
+  </#if>
+</#list>
+
+<#macro assertSliceEquals expected range>
+  <@assertJoinedEquals expected, ('${sliced}[${range}]')?eval />
+</#macro>
+
+<#macro assertSliceFails range>
+  <@assertFails><@consume ('${sliced}[${range}]')?eval /></@>
+</#macro>
+
+<#macro assertJoinedEquals expected actual>
+  <#local actualAsString = actual?isEnumerable?then(actual?join(''), actual)>
+  <@assertEquals expected=expected actual=actualAsString />
+</#macro>
+
+<#macro consume exp>
+  <#if exp?isEnumerable>
+    <#list exp as _></#list>
+  </#if>
+</#macro>
diff --git a/src/test/resources/freemarker/test/templatesuite/testcases.xml b/src/test/resources/freemarker/test/templatesuite/testcases.xml
index d514a2d..95cba52 100644
--- a/src/test/resources/freemarker/test/templatesuite/testcases.xml
+++ b/src/test/resources/freemarker/test/templatesuite/testcases.xml
@@ -18,7 +18,7 @@
   under the License.
 -->
 
-<!DOCTYPE testcases [
+<!DOCTYPE testCases [
   <!ELEMENT testCases (setting?, testCase*)>
   <!ELEMENT testCase (setting?)>
      <!ATTLIST testCase 
@@ -174,6 +174,9 @@
    <testCase name="range-ici-2.3.21" noOutput="true">
       <setting incompatible_improvements="2.3.21, max"/>
    </testCase>
+   <testCase name="range-lazy" noOutput="true">
+      <setting incompatible_improvements="2.3.22"/>
+   </testCase>
    <testCase name="recover" />
    <testCase name="root" />
    <testCase name="setting" noOutput="true" />