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 7e4ab15adb09ea6347ee5052b6b027c69cbdeafe Author: ddekany <[email protected]> AuthorDate: Sun Feb 17 15:39:38 2019 +0100 Added "local lambdas" to parser, which look like Java lambda-s, but are only allowed as parameters in certain built-ins, as they can't work outside the variable scope where they were created. To added new built-ins, ?filter(...) and ?map(...) to try the concept. These can get a "local lambda", or an #ftl function, or a method as parameter. These built-ins support the lazy processing of elements when they are the child of an AST node that explicitly enables that (for now only #list and [...] --- src/main/java/freemarker/core/BuiltIn.java | 14 +- .../core/BuiltInWithParseTimeParameters.java | 21 +- .../java/freemarker/core/BuiltInsForSequences.java | 345 ++++++++++++++++++++- src/main/java/freemarker/core/Environment.java | 38 +++ .../freemarker/core/ExpressionWithFixedResult.java | 73 +++++ src/main/java/freemarker/core/IteratorBlock.java | 41 +-- .../freemarker/core/LocalLambdaExpression.java | 152 +++++++++ src/main/java/freemarker/core/MethodCall.java | 2 +- .../java/freemarker/core/NonMethodException.java | 7 +- .../core/SingleIterationCollectionModel.java | 51 +++ src/main/javacc/FTL.jj | 127 +++++++- src/test/java/freemarker/core/FilterBiTest.java | 142 +++++++++ src/test/java/freemarker/core/MapBiTest.java | 258 +++++++++++++++ 13 files changed, 1234 insertions(+), 37 deletions(-) diff --git a/src/main/java/freemarker/core/BuiltIn.java b/src/main/java/freemarker/core/BuiltIn.java index 7d063ab..450dd6d 100644 --- a/src/main/java/freemarker/core/BuiltIn.java +++ b/src/main/java/freemarker/core/BuiltIn.java @@ -84,7 +84,7 @@ abstract class BuiltIn extends Expression implements Cloneable { static final Set<String> CAMEL_CASE_NAMES = new TreeSet<String>(); static final Set<String> SNAKE_CASE_NAMES = new TreeSet<String>(); - static final int NUMBER_OF_BIS = 279; + static final int NUMBER_OF_BIS = 281; static final HashMap<String, BuiltIn> BUILT_INS_BY_NAME = new HashMap(NUMBER_OF_BIS * 3 / 2 + 1, 1f); static { @@ -115,6 +115,7 @@ abstract class BuiltIn extends Expression implements Cloneable { putBI("esc", new escBI()); putBI("eval", new evalBI()); putBI("exists", new BuiltInsForExistenceHandling.existsBI()); + putBI("filter", new BuiltInsForSequences.filterBI()); putBI("first", new firstBI()); putBI("float", new floatBI()); putBI("floor", new floorBI()); @@ -238,6 +239,7 @@ abstract class BuiltIn extends Expression implements Cloneable { putBI("long", new longBI()); putBI("lower_abc", "lowerAbc", new BuiltInsForNumbers.lower_abcBI()); putBI("lower_case", "lowerCase", new BuiltInsForStringsBasic.lower_caseBI()); + putBI("map", new BuiltInsForSequences.mapBI()); putBI("namespace", new BuiltInsForMultipleTypes.namespaceBI()); putBI("new", new NewBI()); putBI("markup_string", "markupString", new markup_stringBI()); @@ -385,9 +387,19 @@ abstract class BuiltIn extends Expression implements Cloneable { } bi.key = key; bi.target = target; + if (bi.isSingleIterationCollectionTargetSupported()) { + if (target instanceof BuiltInsForSequences.IntermediateStreamOperationLikeBuiltIn) { + ((BuiltInsForSequences.IntermediateStreamOperationLikeBuiltIn) target) + .setLazyProcessingAllowed(true); + } + } return bi; } + protected boolean isSingleIterationCollectionTargetSupported() { + return false; + } + @Override public String getCanonicalForm() { return target.getCanonicalForm() + "?" + key; diff --git a/src/main/java/freemarker/core/BuiltInWithParseTimeParameters.java b/src/main/java/freemarker/core/BuiltInWithParseTimeParameters.java index f17c204..383506f 100644 --- a/src/main/java/freemarker/core/BuiltInWithParseTimeParameters.java +++ b/src/main/java/freemarker/core/BuiltInWithParseTimeParameters.java @@ -85,13 +85,25 @@ abstract class BuiltInWithParseTimeParameters extends SpecialBuiltIn { } } - protected ParseException newArgumentCountException(String ordinalityDesc, Token openParen, Token closeParen) { + protected final ParseException newArgumentCountException(String ordinalityDesc, Token openParen, Token closeParen) { return new ParseException( "?" + key + "(...) " + ordinalityDesc + " parameters", this.getTemplate(), openParen.beginLine, openParen.beginColumn, closeParen.endLine, closeParen.endColumn); } + protected final void checkLocalLambdaParamCount(LocalLambdaExpression localLambdaExp, int expectedParamCount) + throws ParseException { + int actualParamCount = localLambdaExp.getLambdaParameterList().getParameters().size(); + if (actualParamCount != expectedParamCount) { + throw new ParseException( + "?" + key + "(...) parameter lambda expression must declare exactly " + expectedParamCount + " " + + "parameter" + (expectedParamCount > 1 ? "s" : "") + ", but it declared " + + actualParamCount + ".", + localLambdaExp); + } + } + @Override protected Expression deepCloneWithIdentifierReplaced_inner( String replacedIdentifier, Expression replacement, ReplacemenetState replacementState) { @@ -108,5 +120,12 @@ abstract class BuiltInWithParseTimeParameters extends SpecialBuiltIn { protected abstract void cloneArguments(Expression clone, String replacedIdentifier, Expression replacement, ReplacemenetState replacementState); + + /** + * If parameter expressions can be {@link LocalLambdaExpression}-s. + */ + protected boolean isLocalLambdaParameterSupported() { + return false; + } } diff --git a/src/main/java/freemarker/core/BuiltInsForSequences.java b/src/main/java/freemarker/core/BuiltInsForSequences.java index 9bc8d95..0bb2bd9 100644 --- a/src/main/java/freemarker/core/BuiltInsForSequences.java +++ b/src/main/java/freemarker/core/BuiltInsForSequences.java @@ -37,6 +37,7 @@ import freemarker.template.TemplateCollectionModelEx; import freemarker.template.TemplateDateModel; import freemarker.template.TemplateException; import freemarker.template.TemplateHashModel; +import freemarker.template.TemplateMethodModel; import freemarker.template.TemplateMethodModelEx; import freemarker.template.TemplateModel; import freemarker.template.TemplateModelException; @@ -141,7 +142,12 @@ class BuiltInsForSequences { } static class firstBI extends BuiltIn { - + + @Override + protected boolean isSingleIterationCollectionTargetSupported() { + return true; + } + @Override TemplateModel _eval(Environment env) throws TemplateException { @@ -177,7 +183,12 @@ class BuiltInsForSequences { } static class joinBI extends BuiltIn { - + + @Override + protected boolean isSingleIterationCollectionTargetSupported() { + return true; + } + private class BIMethodForCollection implements TemplateMethodModelEx { private final Environment env; @@ -246,7 +257,7 @@ class BuiltInsForSequences { throw new NonSequenceOrCollectionException(target, model, env); } } - + } static class lastBI extends BuiltInForSequence { @@ -289,6 +300,12 @@ class BuiltInsForSequences { } static class seq_containsBI extends BuiltIn { + + @Override + protected boolean isSingleIterationCollectionTargetSupported() { + return true; + } + private class BIMethodForCollection implements TemplateMethodModelEx { private TemplateCollectionModel m_coll; private Environment m_env; @@ -355,7 +372,12 @@ class BuiltInsForSequences { } static class seq_index_ofBI extends BuiltIn { - + + @Override + protected boolean isSingleIterationCollectionTargetSupported() { + return true; + } + private class BIMethod implements TemplateMethodModelEx { protected final TemplateSequenceModel m_seq; @@ -893,6 +915,11 @@ class BuiltInsForSequences { } @Override + protected boolean isSingleIterationCollectionTargetSupported() { + return true; + } + + @Override TemplateModel _eval(Environment env) throws TemplateException { TemplateModel model = target.eval(env); @@ -951,8 +978,314 @@ class BuiltInsForSequences { } } - + + /** + * Built-in that's similar to an Java 8 Stream intermediate operation. To be on the safe side, by default these + * are eager, and just produce a {@link TemplateSequenceModel}. But when circumstances allow, they become + * lazy, similarly to Java 8 Stream-s. Another characteristic of the built-ins that they usually accept + * lambda expressions as parameters. + */ + static abstract class IntermediateStreamOperationLikeBuiltIn extends BuiltInWithParseTimeParameters { + + private Expression elementTransformerExp; + private ElementTransformer precreatedElementTransformer; + private boolean lazyProcessingAllowed; + + @Override + void bindToParameters(List<Expression> parameters, Token openParen, Token closeParen) throws ParseException { + if (parameters.size() != 1) { + throw newArgumentCountException("requires exactly 1", openParen, closeParen); + } + this.elementTransformerExp = parameters.get(0); + if (elementTransformerExp instanceof LocalLambdaExpression) { + LocalLambdaExpression localLambdaExp = (LocalLambdaExpression) elementTransformerExp; + checkLocalLambdaParamCount(localLambdaExp, 1); + // We can't do this with other kind of expressions, as they need to be evaluated on runtime: + precreatedElementTransformer = new LocalLambdaElementTransformer(localLambdaExp); + } + + if (target instanceof IntermediateStreamOperationLikeBuiltIn) { + ((IntermediateStreamOperationLikeBuiltIn) target).setLazyProcessingAllowed(true); + } + } + + @Override + protected boolean isLocalLambdaParameterSupported() { + return true; + } + + boolean isLazyProcessingAllowed() { + return lazyProcessingAllowed; + } + + /** + * Used to allow processing of the collection or sequence elements on an as-needed basis, similarly as + * Java 8 Stream intermediate operations do it. This is initially {@code false}. The containing expression or + * directive sets it to {@code true} if it can ensure that: + * <ul> + * <li>The returned {@link TemplateCollectionModel} is traversed only once, more specifically, + * {@link TemplateCollectionModel#iterator()} is called only once. + * <li>When the methods of the collection or iterator are called, the context provided by + * the {@link Environment} (such as the local context stack) is similar to the context from where the + * built-in was called. This is required as lambda expression are {@link LocalLambdaExpression}-s. + * </ul> + */ + void setLazyProcessingAllowed(boolean lazyProcessingAllowed) { + this.lazyProcessingAllowed = lazyProcessingAllowed; + } + + protected List<Expression> getArgumentsAsList() { + return Collections.singletonList(elementTransformerExp); + } + + protected int getArgumentsCount() { + return 1; + } + + protected Expression getArgumentParameterValue(int argIdx) { + if (argIdx != 0) { + throw new IndexOutOfBoundsException(); + } + return elementTransformerExp; + } + + protected Expression getElementTransformerExp() { + return elementTransformerExp; + } + + protected void cloneArguments( + Expression clone, String replacedIdentifier, Expression replacement, ReplacemenetState replacementState) { + ((IntermediateStreamOperationLikeBuiltIn) clone).elementTransformerExp + = elementTransformerExp.deepCloneWithIdentifierReplaced( + replacedIdentifier, replacement, replacementState); + } + + TemplateModel _eval(Environment env) throws TemplateException { + TemplateModel lho = target.eval(env); + TemplateModelIterator lhoIterator = getTemplateModelIterator(env, lho); + return calculateResult(lhoIterator, lho, evalElementTransformerExp(env), env); + } + + private ElementTransformer evalElementTransformerExp(Environment env) throws TemplateException { + if (precreatedElementTransformer != null) { + return precreatedElementTransformer; + } + + TemplateModel elementTransformerModel = elementTransformerExp.eval(env); + if (elementTransformerModel instanceof TemplateMethodModel) { + return new MethodElementTransformer((TemplateMethodModel) elementTransformerModel); + } else if (elementTransformerModel instanceof Macro) { + return new FunctionElementTransformer((Macro) elementTransformerModel, elementTransformerExp); + } else { + throw new NonMethodException(elementTransformerExp, elementTransformerModel, true, true, null, env); + } + } + + private TemplateModelIterator getTemplateModelIterator(Environment env, TemplateModel model) throws TemplateModelException, + NonSequenceOrCollectionException, InvalidReferenceException { + if (model instanceof TemplateCollectionModel) { + return ((TemplateCollectionModel) model).iterator(); + } else if (model instanceof TemplateSequenceModel) { + return new SequenceIterator((TemplateSequenceModel) model); + } else if (model instanceof TemplateModelIterator) { // For a stream mode LHO + return (TemplateModelIterator) model; + } else { + throw new NonSequenceOrCollectionException(target, model, env); + } + } + + /** + * @param lhoIterator Use this to iterate through the items + * @param lho Maybe needed for operations specific to the built-in, like getting the size + * + * @return {@link TemplateSequenceModel} or {@link TemplateCollectionModel} or {@link TemplateModelIterator}. + */ + protected abstract TemplateModel calculateResult( + TemplateModelIterator lhoIterator, TemplateModel lho, ElementTransformer elementTransformer, + Environment env) throws TemplateException; + + interface ElementTransformer { + TemplateModel transformElement(TemplateModel element, Environment env) throws TemplateException; + } + + private static class LocalLambdaElementTransformer implements ElementTransformer { + private final LocalLambdaExpression elementTransformerExp; + + public LocalLambdaElementTransformer(LocalLambdaExpression elementTransformerExp) { + this.elementTransformerExp = elementTransformerExp; + } + + public TemplateModel transformElement(TemplateModel element, Environment env) throws TemplateException { + return elementTransformerExp.invokeLambdaDefinedFunction(element, env); + } + } + + private static class MethodElementTransformer implements ElementTransformer { + private final TemplateMethodModel elementTransformer; + + public MethodElementTransformer(TemplateMethodModel elementTransformer) { + this.elementTransformer = elementTransformer; + } + + public TemplateModel transformElement(TemplateModel element, Environment env) + throws TemplateModelException { + Object result = elementTransformer.exec(Collections.singletonList(element)); + return result instanceof TemplateModel ? (TemplateModel) result : env.getObjectWrapper().wrap(result); + } + } + + private static class FunctionElementTransformer implements ElementTransformer { + private final Macro templateTransformer; + private final Expression elementTransformerExp; + + public FunctionElementTransformer(Macro templateTransformer, Expression elementTransformerExp) { + this.templateTransformer = templateTransformer; + this.elementTransformerExp = elementTransformerExp; + } + + public TemplateModel transformElement(TemplateModel element, Environment env) throws + TemplateException { + ExpressionWithFixedResult functionArgExp = new ExpressionWithFixedResult( + element, elementTransformerExp); + return env.invokeFunction(env, templateTransformer, + Collections.singletonList(functionArgExp), + elementTransformerExp); + } + } + + } + + static class filterBI extends IntermediateStreamOperationLikeBuiltIn { + + protected TemplateModel calculateResult( + final TemplateModelIterator lhoIterator, final TemplateModel lho, + final ElementTransformer elementTransformer, + final Environment env) throws TemplateException { + if (!isLazyProcessingAllowed()) { + List<TemplateModel> resultList = new ArrayList<TemplateModel>(); + while (lhoIterator.hasNext()) { + TemplateModel element = lhoIterator.next(); + if (elementMatches(element, elementTransformer, env)) { + resultList.add(element); + } + } + return new TemplateModelListSequence(resultList); + } else { + return new SingleIterationCollectionModel( + new TemplateModelIterator() { + boolean prefetchDone; + TemplateModel prefetchedElement; + boolean prefetchedEndOfIterator; + + public TemplateModel next() throws TemplateModelException { + ensurePrefetchDone(); + if (prefetchedEndOfIterator) { + throw new IllegalStateException("next() was called when hasNext() is false"); + } + prefetchDone = false; + return prefetchedElement; + } + + public boolean hasNext() throws TemplateModelException { + ensurePrefetchDone(); + return !prefetchedEndOfIterator; + } + + private void ensurePrefetchDone() throws TemplateModelException { + if (prefetchDone) { + return; + } + + boolean conclusionReached = false; + do { + if (lhoIterator.hasNext()) { + TemplateModel element = lhoIterator.next(); + boolean elementMatched; + try { + elementMatched = elementMatches(element, elementTransformer, env); + } catch (TemplateException e) { + throw new _TemplateModelException(e, env, "Failed to transform element"); + } + if (elementMatched) { + prefetchedElement = element; + conclusionReached = true; + } + } else { + prefetchedEndOfIterator = true; + prefetchedElement = null; + conclusionReached = true; + } + } while (!conclusionReached); + prefetchDone = true; + } + } + ); + } + } + + private boolean elementMatches(TemplateModel element, ElementTransformer elementTransformer, Environment env) throws + TemplateException { + TemplateModel transformedElement = elementTransformer.transformElement(element, env); + if (!(transformedElement instanceof TemplateBooleanModel)) { + if (transformedElement == null) { + throw new _TemplateModelException(getElementTransformerExp(), env, + "The element transformer function has returned no return value (has returned null) " + + "instead of a boolean."); + } + throw new _TemplateModelException(getElementTransformerExp(), env, + "The element transformer function had to return a boolean value, but it has returned ", + new _DelayedAOrAn(new _DelayedFTLTypeDescription(transformedElement)), + " instead."); + } + return ((TemplateBooleanModel) transformedElement).getAsBoolean(); + } + + } + + static class mapBI extends IntermediateStreamOperationLikeBuiltIn { + + protected TemplateModel calculateResult( + final TemplateModelIterator lhoIterator, TemplateModel lho, final ElementTransformer elementTransformer, + final Environment env) throws TemplateException { + if (!isLazyProcessingAllowed()) { + List<TemplateModel> resultList = new ArrayList<TemplateModel>(); + while (lhoIterator.hasNext()) { + resultList.add(fetchAndTransformNextElement(lhoIterator, elementTransformer, env)); + } + return new TemplateModelListSequence(resultList); + } else { + return new SingleIterationCollectionModel( + new TemplateModelIterator() { + + public TemplateModel next() throws TemplateModelException { + try { + return fetchAndTransformNextElement(lhoIterator, elementTransformer, env); + } catch (TemplateException e) { + throw new _TemplateModelException(e, env, "Failed to transform element"); + } + } + + public boolean hasNext() throws TemplateModelException { + return lhoIterator.hasNext(); + } + }); + } + } + + private TemplateModel fetchAndTransformNextElement( + TemplateModelIterator lhoIterator, ElementTransformer elementTransformer, Environment env) + throws TemplateException { + TemplateModel transformedElement = elementTransformer.transformElement(lhoIterator.next(), env); + if (transformedElement == null) { + throw new _TemplateModelException(getElementTransformerExp(), env, + "The element transformer function has returned no return value (has returned null)."); + } + return transformedElement; + } + + } + // Can't be instantiated private BuiltInsForSequences() { } - + } \ No newline at end of file diff --git a/src/main/java/freemarker/core/Environment.java b/src/main/java/freemarker/core/Environment.java index 376b838..3498a03 100644 --- a/src/main/java/freemarker/core/Environment.java +++ b/src/main/java/freemarker/core/Environment.java @@ -31,6 +31,7 @@ import java.text.DecimalFormatSymbols; import java.text.NumberFormat; import java.util.ArrayList; import java.util.Collection; +import java.util.Collections; import java.util.Date; import java.util.HashMap; import java.util.IdentityHashMap; @@ -649,6 +650,43 @@ public final class Environment extends Configurable { } /** + * Evaluate expression with shadowing a single variable with a new local variable. + * + * @since 2.3.29 + */ + TemplateModel evaluateWithNewLocal(Expression exp, String lambdaArgName, TemplateModel lamdaArgValue) + throws TemplateException { + pushLocalContext(new LocalContextWithNewLocal(lambdaArgName, lamdaArgValue)); + try { + return exp.eval(this); + } finally { + localContextStack.pop(); + } + } + + /** + * Specialization for 1 local variables. + */ + private static class LocalContextWithNewLocal implements LocalContext { + private final String lambdaArgName; + private final TemplateModel lambdaArgValue; + + public LocalContextWithNewLocal(String lambdaArgName, TemplateModel lambdaArgValue) { + this.lambdaArgName = lambdaArgName; + this.lambdaArgValue = lambdaArgValue; + } + + public TemplateModel getLocalVariable(String name) throws TemplateModelException { + // TODO [lambda] Do not allow fallback (i.e., introduce untransparent null-s) + return name.equals(lambdaArgName) ? lambdaArgValue : null; + } + + public Collection getLocalVariableNames() throws TemplateModelException { + return Collections.singleton(lambdaArgName); + } + } + + /** * Used for {@code #visit} and {@code #recurse}. */ void invokeNodeHandlerFor(TemplateNodeModel node, TemplateSequenceModel namespaces) diff --git a/src/main/java/freemarker/core/ExpressionWithFixedResult.java b/src/main/java/freemarker/core/ExpressionWithFixedResult.java new file mode 100644 index 0000000..37aee8f --- /dev/null +++ b/src/main/java/freemarker/core/ExpressionWithFixedResult.java @@ -0,0 +1,73 @@ +/* + * 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.TemplateException; +import freemarker.template.TemplateModel; + +/** + * Mimics an expression (the "source expression"), but returns the predefined "fixed result" whenever it's evaluated. + */ +class ExpressionWithFixedResult extends Expression { + private final TemplateModel fixedResult; + private final Expression sourceExpression; + + ExpressionWithFixedResult(TemplateModel fixedResult, Expression sourceExpression) { + this.fixedResult = fixedResult; + this.sourceExpression = sourceExpression; + } + + TemplateModel _eval(Environment env) throws TemplateException { + return fixedResult; + } + + boolean isLiteral() { + return sourceExpression.isLiteral(); + } + + protected Expression deepCloneWithIdentifierReplaced_inner(String replacedIdentifier, Expression replacement, + ReplacemenetState replacementState) { + return new ExpressionWithFixedResult( + fixedResult, + sourceExpression.deepCloneWithIdentifierReplaced(replacedIdentifier, replacement, replacementState)); + } + + public String getCanonicalForm() { + return sourceExpression.getCanonicalForm(); + } + + String getNodeTypeSymbol() { + return sourceExpression.getNodeTypeSymbol(); + } + + int getParameterCount() { + return sourceExpression.getParameterCount(); + } + + Object getParameterValue(int idx) { + return sourceExpression.getParameterValue(idx); + } + + ParameterRole getParameterRole(int idx) { + return sourceExpression.getParameterRole(idx); + } + + +} diff --git a/src/main/java/freemarker/core/IteratorBlock.java b/src/main/java/freemarker/core/IteratorBlock.java index bbb6773..e8ed614 100644 --- a/src/main/java/freemarker/core/IteratorBlock.java +++ b/src/main/java/freemarker/core/IteratorBlock.java @@ -49,6 +49,7 @@ final class IteratorBlock extends TemplateElement { private final String loopVar2Name; private final boolean hashListing; private final boolean forEach; + private final boolean fetchElementsOutsideLoopVarContext; /** * @param listedExp @@ -82,6 +83,13 @@ final class IteratorBlock extends TemplateElement { setChildren(childrenBeforeElse); this.hashListing = hashListing; this.forEach = forEach; + + if (listedExp instanceof BuiltInsForSequences.IntermediateStreamOperationLikeBuiltIn) { + ((BuiltInsForSequences.IntermediateStreamOperationLikeBuiltIn) listedExp).setLazyProcessingAllowed(true); + fetchElementsOutsideLoopVarContext = true; + } else { + fetchElementsOutsideLoopVarContext = false; + } } boolean isHashListing() { @@ -243,8 +251,7 @@ final class IteratorBlock extends TemplateElement { } void loopForItemsElement(Environment env, TemplateElement[] childBuffer, String loopVarName, String loopVar2Name) - throws NonSequenceOrCollectionException, TemplateModelException, InvalidReferenceException, - TemplateException, IOException { + throws TemplateException, IOException { try { if (alreadyEntered) { throw new _MiscTemplateException(env, @@ -265,16 +272,14 @@ final class IteratorBlock extends TemplateElement { * each list item once, otherwise once if {@link #listedValue} isn't empty. */ private boolean executeNestedContent(Environment env, TemplateElement[] childBuffer) - throws TemplateModelException, TemplateException, IOException, NonSequenceOrCollectionException, - InvalidReferenceException { + throws TemplateException, IOException { return !hashListing ? executedNestedContentForCollOrSeqListing(env, childBuffer) : executedNestedContentForHashListing(env, childBuffer); } private boolean executedNestedContentForCollOrSeqListing(Environment env, TemplateElement[] childBuffer) - throws TemplateModelException, IOException, TemplateException, - NonSequenceOrCollectionException, InvalidReferenceException { + throws IOException, TemplateException { final boolean listNotEmpty; if (listedValue instanceof TemplateCollectionModel) { final TemplateCollectionModel collModel = (TemplateCollectionModel) listedValue; @@ -284,22 +289,22 @@ final class IteratorBlock extends TemplateElement { listNotEmpty = iterModel.hasNext(); if (listNotEmpty) { if (loopVarName != null) { - listLoop: do { - loopVar = iterModel.next(); - hasNext = iterModel.hasNext(); - try { - env.visit(childBuffer); - } catch (BreakOrContinueException br) { - if (br == BreakOrContinueException.BREAK_INSTANCE) { - break listLoop; - } + listLoop: do { + loopVar = iterModel.next(); + hasNext = iterModel.hasNext(); + try { + env.visit(childBuffer); + } catch (BreakOrContinueException br) { + if (br == BreakOrContinueException.BREAK_INSTANCE) { + break listLoop; } - index++; - } while (hasNext); + } + index++; + } while (hasNext); openedIterator = null; } else { // We must reuse this later, because TemplateCollectionModel-s that wrap an Iterator only - // allow one iterator() call. + // allow one iterator() call. (Also those returned by ?filter, etc. with lazy processing on.) openedIterator = iterModel; env.visit(childBuffer); } diff --git a/src/main/java/freemarker/core/LocalLambdaExpression.java b/src/main/java/freemarker/core/LocalLambdaExpression.java new file mode 100644 index 0000000..537efb1 --- /dev/null +++ b/src/main/java/freemarker/core/LocalLambdaExpression.java @@ -0,0 +1,152 @@ +/* + * 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 java.util.List; + +import freemarker.template.TemplateException; +import freemarker.template.TemplateModel; + +/** + * A local lambada expression is a lambda expression that creates a function that can only be called from the same + * context where it was created, and thus it doesn't need closure support. As of this writing (2019-02), this is the + * only kind of lambda expression supported, as supporting closures would add overhead to many basic operations, while + * local lambdas are "good enough" for the main use cases in templates (for filtering/transforming lists). Also, + * closures can be quite confusing when the lambda expression refers to variables that are not effectively final, + * such as a loop variable. So that's yet another issue to address if we go for less restricted lambdas. + */ +final class LocalLambdaExpression extends Expression { + + private final LambdaParameterList lho; + private final Expression rho; + + LocalLambdaExpression(LambdaParameterList lho, Expression rho) { + this.lho = lho; + this.rho = rho; + } + + @Override + public String getCanonicalForm() { + return lho.getCanonicalForm() + " -> " + rho.getCanonicalForm(); + } + + @Override + String getNodeTypeSymbol() { + return "->"; + } + + TemplateModel _eval(Environment env) throws TemplateException { + throw new TemplateException("Can't get lambda expression as a value: Lambdas currently can only be used on a " + + "few special places.", + env); + } + + /** + * Call the function defined by the lambda expression; overload specialized for 1 argument, the most common case. + */ + TemplateModel invokeLambdaDefinedFunction(TemplateModel argValue, Environment env) throws TemplateException { + return env.evaluateWithNewLocal(rho, lho.getParameters().get(0).getName(), argValue); + } + + @Override + boolean isLiteral() { + // As we don't support true lambdas, they can't be evaluted in parse time. + return false; + } + + @Override + protected Expression deepCloneWithIdentifierReplaced_inner( + String replacedIdentifier, Expression replacement, ReplacemenetState replacementState) { + // TODO [lambda] replacement in lho is illegal; detect it + return new LocalLambdaExpression( + lho, + rho.deepCloneWithIdentifierReplaced(replacedIdentifier, replacement, replacementState)); + } + + @Override + int getParameterCount() { + return 2; + } + + @Override + Object getParameterValue(int idx) { + // TODO [lambda] should be similar to #function + switch (idx) { + case 0: return lho; + case 1: return rho; + default: throw new IndexOutOfBoundsException(); + } + } + + @Override + ParameterRole getParameterRole(int idx) { + return ParameterRole.forBinaryOperatorOperand(idx); + } + + LambdaParameterList getLambdaParameterList() { + return lho; + } + + /** The left side of the `->`. */ + static class LambdaParameterList { + private final Token openingParenthesis; + private final Token closingParenthesis; + private final List<Identifier> parameters; + + public LambdaParameterList(Token openingParenthesis, List<Identifier> parameters, Token closingParenthesis) { + this.openingParenthesis = openingParenthesis; + this.closingParenthesis = closingParenthesis; + this.parameters = parameters; + } + + /** Maybe {@code null} */ + public Token getOpeningParenthesis() { + return openingParenthesis; + } + + /** Maybe {@code null} */ + public Token getClosingParenthesis() { + return closingParenthesis; + } + + public List<Identifier> getParameters() { + return parameters; + } + + public String getCanonicalForm() { + if (parameters.size() == 1) { + return parameters.get(0).getCanonicalForm(); + } else { + StringBuilder sb = new StringBuilder(); + sb.append('('); + for (int i = 0; i < parameters.size(); i++) { + if (i != 0) { + sb.append(", "); + } + Identifier parameter = parameters.get(i); + sb.append(parameter.getCanonicalForm()); + } + sb.append(')'); + return sb.toString(); + } + } + } + +} diff --git a/src/main/java/freemarker/core/MethodCall.java b/src/main/java/freemarker/core/MethodCall.java index afe3c83..74e20a4 100644 --- a/src/main/java/freemarker/core/MethodCall.java +++ b/src/main/java/freemarker/core/MethodCall.java @@ -64,7 +64,7 @@ final class MethodCall extends Expression { } else if (targetModel instanceof Macro) { return env.invokeFunction(env, (Macro) targetModel, arguments.items, this); } else { - throw new NonMethodException(target, targetModel, true, null, env); + throw new NonMethodException(target, targetModel, true, false, null, env); } } diff --git a/src/main/java/freemarker/core/NonMethodException.java b/src/main/java/freemarker/core/NonMethodException.java index a6eff89..4ba3009 100644 --- a/src/main/java/freemarker/core/NonMethodException.java +++ b/src/main/java/freemarker/core/NonMethodException.java @@ -59,7 +59,7 @@ public class NonMethodException extends UnexpectedTypeException { NonMethodException( Expression blamed, TemplateModel model, String[] tips, Environment env) throws InvalidReferenceException { - this(blamed, model, false, tips, env); + this(blamed, model, false, false, tips, env); } /** @@ -68,10 +68,11 @@ public class NonMethodException extends UnexpectedTypeException { * @since 2.3.29 */ NonMethodException( - Expression blamed, TemplateModel model, boolean allowFTLFunction, String[] tips, Environment env) + Expression blamed, TemplateModel model, boolean allowFTLFunction, boolean allowLambdaExp, + String[] tips, Environment env) throws InvalidReferenceException { super(blamed, model, - allowFTLFunction ? "method or function" : "method", + "method" + (allowFTLFunction ? " or function" : "") + (allowLambdaExp ? " or lambda expression" : ""), allowFTLFunction ? EXPECTED_TYPES_WITH_FUNCTION : EXPECTED_TYPES, tips, env); } diff --git a/src/main/java/freemarker/core/SingleIterationCollectionModel.java b/src/main/java/freemarker/core/SingleIterationCollectionModel.java new file mode 100644 index 0000000..292bcd3 --- /dev/null +++ b/src/main/java/freemarker/core/SingleIterationCollectionModel.java @@ -0,0 +1,51 @@ +/* + * 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.TemplateCollectionModel; +import freemarker.template.TemplateModel; +import freemarker.template.TemplateModelException; +import freemarker.template.TemplateModelIterator; +import freemarker.template.utility.NullArgumentException; + +/** + * Used where we really want to return/pass a {@link TemplateModelIterator}, but the API requires us to return + * a {@link TemplateModel}. + * + * @since 2.3.29 + */ +class SingleIterationCollectionModel implements TemplateCollectionModel { + private TemplateModelIterator iterator; + + public SingleIterationCollectionModel(TemplateModelIterator iterator) { + NullArgumentException.check(iterator); + this.iterator = iterator; + } + + public TemplateModelIterator iterator() throws TemplateModelException { + if (iterator == null) { + throw new IllegalStateException( + "Can't return the iterator again, as this TemplateCollectionModel can only be iterated once."); + } + TemplateModelIterator result = iterator; + iterator = null; + return result; + } +} diff --git a/src/main/javacc/FTL.jj b/src/main/javacc/FTL.jj index a2abb4d..a157871 100644 --- a/src/main/javacc/FTL.jj +++ b/src/main/javacc/FTL.jj @@ -29,6 +29,7 @@ PARSER_BEGIN(FMParser) package freemarker.core; +import freemarker.core.LocalLambdaExpression.LambdaParameterList; import freemarker.template.*; import freemarker.template.utility.*; import java.io.*; @@ -1278,6 +1279,8 @@ TOKEN: | <ESCAPED_GTE : "gte" | "\\gte" | ">="> | + <LAMBDA_ARROW : "->" | "->"> + | <PLUS : "+"> | <MINUS : "-"> @@ -1993,9 +1996,6 @@ Expression RangeExpression() : } } - - - Expression AndExpression() : { Expression lhs, rhs, result; @@ -2122,8 +2122,10 @@ BuiltinVariable BuiltinVariable() : parseTimeValue = new SimpleScalar(outputFormat.getName()); } else if (nameStr.equals(BuiltinVariable.AUTO_ESC) || nameStr.equals(BuiltinVariable.AUTO_ESC_CC)) { parseTimeValue = autoEscaping ? TemplateBooleanModel.TRUE : TemplateBooleanModel.FALSE; - } else { parseTimeValue = null; - } + } else { + parseTimeValue = null; + } + result = new BuiltinVariable(name, token_source, parseTimeValue); result.setLocation(template, dot, name); @@ -2235,13 +2237,31 @@ Expression BuiltIn(Expression lhoExp) : } } [ - LOOKAHEAD({ result instanceof BuiltInWithParseTimeParameters }) + LOOKAHEAD({ + result instanceof BuiltInWithParseTimeParameters + && !((BuiltInWithParseTimeParameters) result).isLocalLambdaParameterSupported() }) openParen = <OPEN_PAREN> args = PositionalArgs() closeParen = <CLOSE_PAREN> { result.setLocation(template, lhoExp, closeParen); ((BuiltInWithParseTimeParameters) result).bindToParameters(args, openParen, closeParen); - + + return result; + } + ] + // In principle we should embed the BuiltInWithParseTimeParameters LOOKAHEAD into the + // isLocalLambdaParameterSupported LOOKAHEAD, but then the `result` variable was out of scope in the code + // generated for the nested LOOKAHEAD. So we had to flatten this by checking isLocalLambdaParameterSupported twice. + [ + LOOKAHEAD({ + result instanceof BuiltInWithParseTimeParameters + && ((BuiltInWithParseTimeParameters) result).isLocalLambdaParameterSupported() }) + openParen = <OPEN_PAREN> + args = PositionalMaybeLambdaArgs() + closeParen = <CLOSE_PAREN> { + result.setLocation(template, lhoExp, closeParen); + ((BuiltInWithParseTimeParameters) result).bindToParameters(args, openParen, closeParen); + return result; } ] @@ -2251,6 +2271,78 @@ Expression BuiltIn(Expression lhoExp) : } } +// Only supported as the argument of certain built-ins, so it's not called inside Expression. +Expression LocalLambdaExpression() : +{ + LambdaParameterList lhs; + Expression rhs, result; +} +{ + ( + LOOKAHEAD(LambdaExpressionParameterList() <LAMBDA_ARROW>) + ( + lhs = LambdaExpressionParameterList() + <LAMBDA_ARROW> + rhs = OrExpression() + { + result = new LocalLambdaExpression(lhs, rhs); + if (lhs.getOpeningParenthesis() != null) { + // (args) -> exp + result.setLocation(template, lhs.getOpeningParenthesis(), rhs); + } else { + // singleArg -> exp + result.setLocation(template, lhs.getParameters().get(0), rhs); + } +} + ) + | + result = OrExpression() + ) + { + return result; + } +} + +LambdaParameterList LambdaExpressionParameterList() : +{ + Token openParen = null; + Token closeParen = null; + List<Identifier> params = null; + Identifier param; +} +{ + ( + ( + openParen = <OPEN_PAREN> + [ + param = Identifier() + { + params = new ArrayList<Identifier>(4); + params.add(param); + } + ( + <COMMA> + param = Identifier() + { + params.add(param); + } + )* + ] + closeParen = <CLOSE_PAREN> + ) + | + param = Identifier() + { + params = Collections.<Identifier>singletonList(param); + } + ) + { + return new LambdaParameterList( + openParen, + params != null ? params : Collections.<Identifier>emptyList(), + closeParen); + } +} /** * production for when a key is specified by <DOT> + keyname @@ -3636,6 +3728,27 @@ ArrayList PositionalArgs() : } } +/** + * Like PositionalArgs, but allows lambdas. This is separate as it's slower, while lambdas are only allowed on a few + * places. + */ +ArrayList PositionalMaybeLambdaArgs() : +{ + ArrayList result = new ArrayList(); + Expression arg; +} +{ + [ + arg = LocalLambdaExpression() { result.add(arg); } + ( + [<COMMA>] + arg = LocalLambdaExpression() { result.add(arg); } + )* + ] + { + return result; + } +} Comment Comment() : { diff --git a/src/test/java/freemarker/core/FilterBiTest.java b/src/test/java/freemarker/core/FilterBiTest.java new file mode 100644 index 0000000..14e37ac --- /dev/null +++ b/src/test/java/freemarker/core/FilterBiTest.java @@ -0,0 +1,142 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * 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 java.util.List; + +import org.junit.Test; + +import com.google.common.collect.ImmutableList; + +import freemarker.template.TemplateException; +import freemarker.test.TemplateTest; + +public class FilterBiTest extends TemplateTest { + + private static class TestParam { + private final List<?> list; + private final String result; + + public TestParam(List<?> list, String result) { + this.list = list; + this.result = result; + } + } + + private static final List<TestParam> TEST_PARAMS = ImmutableList.of( + new TestParam(ImmutableList.of("a", "aX", "bX", "b", "cX", "c"), "a, b, c"), + new TestParam(ImmutableList.of("a", "b", "c"), "a, b, c"), + new TestParam(ImmutableList.of("aX", "bX", "a", "b", "c", "cX", "cX"), "a, b, c"), + new TestParam(ImmutableList.of("aX", "bX", "cX"), ""), + new TestParam(ImmutableList.of(), "") + ); + + @Test + public void testFilterWithLambda() throws Exception { + for (TestParam testParam : TEST_PARAMS) { + addToDataModel("xs", testParam.list); + assertOutput( + "<#list xs?filter(it -> !it?contains('X')) as x>${x}<#sep>, </#list>", + testParam.result); + assertOutput( + "<#assign fxs = xs?filter(it -> !it?contains('X'))>" + + "${fxs?join(', ')}", + testParam.result); + } + } + + @Test + public void testFilterWithFunction() throws Exception { + for (TestParam testParam : TEST_PARAMS) { + addToDataModel("xs", testParam.list); + String functionDef = "<#function noX s><#return !s?contains('X')></#function>"; + assertOutput( + functionDef + + "<#list xs?filter(noX) as x>${x}<#sep>, </#list>", + testParam.result); + assertOutput( + functionDef + + "<#assign fxs = xs?filter(noX)>" + + "${fxs?join(', ')}", + testParam.result); + } + } + + @Test + public void testFilterWithMethod() throws Exception { + for (TestParam testParam : TEST_PARAMS) { + addToDataModel("xs", testParam.list); + addToDataModel("obj", new FilterObject()); + assertOutput( + "<#list xs?filter(obj.noX) as x>${x}<#sep>, </#list>", + testParam.result); + assertOutput( + "<#assign fxs = xs?filter(obj.noX)>" + + "${fxs?join(', ')}", + testParam.result); + } + } + + @Test + public void testWithNumberElements() throws Exception { + addToDataModel("xs", ImmutableList.of(1, 1.5, 2, 2.3, 3)); + addToDataModel("obj", new FilterObject()); + assertOutput( + "<#list xs?filter(n -> n == n?int) as x>${x}<#sep>, </#list>", + "1, 2, 3"); + assertOutput( + "<#function isInteger n><#return n == n?int></#function>" + + "<#list xs?filter(isInteger) as x>${x}<#sep>, </#list>", + "1, 2, 3"); + assertOutput( + "<#list xs?filter(obj.isInteger) as x>${x}<#sep>, </#list>", + "1, 2, 3"); + } + + @Test + public void testErrorMessages() { + assertErrorContains("${1?filter(it -> true)}", TemplateException.class, + "sequence or collection", "number"); + assertErrorContains("${[]?filter(1)}", TemplateException.class, + "method or function or lambda", "number"); + assertErrorContains("${['x']?filter(it -> 1)}", TemplateException.class, + "boolean", "number"); + assertErrorContains("<#function f></#function>${['x']?filter(f)}", TemplateException.class, + "Function", "0 parameters", "1"); + assertErrorContains("<#function f x y z></#function>${['x']?filter(f)}", TemplateException.class, + "function", "parameter \"y\""); + assertErrorContains("<#function f x></#function>${['x']?filter(f)}", TemplateException.class, + "boolean", "null"); + assertErrorContains("${[]?filter(() -> true)}", ParseException.class, + "lambda", "1 parameter", "declared 0"); + assertErrorContains("${[]?filter((i, j) -> true)}", ParseException.class, + "lambda", "1 parameter", "declared 2"); + } + + public static class FilterObject { + public boolean noX(String s) { + return !s.contains("X"); + } + public boolean isInteger(double n) { + return n == (int) n; + } + } + +} diff --git a/src/test/java/freemarker/core/MapBiTest.java b/src/test/java/freemarker/core/MapBiTest.java new file mode 100644 index 0000000..56bca58 --- /dev/null +++ b/src/test/java/freemarker/core/MapBiTest.java @@ -0,0 +1,258 @@ +/* + * 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 java.math.BigDecimal; +import java.util.List; + +import org.junit.Test; + +import com.google.common.collect.ImmutableList; + +import freemarker.template.Configuration; +import freemarker.template.TemplateException; +import freemarker.test.TemplateTest; + +public class MapBiTest extends TemplateTest { + + private static class TestParam { + private final List<?> list; + private final String result; + + public TestParam(List<?> list, String result) { + this.list = list; + this.result = result; + } + } + + private static final List<TestParam> TEST_PARAMS = ImmutableList.of( + new TestParam(ImmutableList.of("a", "b", "c"), "A, B, C"), + new TestParam(ImmutableList.of("a"), "A"), + new TestParam(ImmutableList.of(), "") + ); + + @Override + protected Configuration createConfiguration() throws Exception { + Configuration cfg = super.createConfiguration(); + cfg.setNumberFormat("0.####"); + cfg.setBooleanFormat("c"); + return cfg; + } + + @Test + public void testFilterWithLambda() throws Exception { + for (TestParam testParam : TEST_PARAMS) { + addToDataModel("xs", testParam.list); + // Lazy: + assertOutput( + "<#list xs?map(it -> it?upperCase) as x>${x}<#sep>, </#list>", + testParam.result); + // Eager: + assertOutput( + "<#assign fxs = xs?map(it -> it?upperCase)>" + + "${fxs?join(', ')}", + testParam.result); + } + } + + @Test + public void testFilterWithFunction() throws Exception { + for (TestParam testParam : TEST_PARAMS) { + addToDataModel("xs", testParam.list); + String functionDef = "<#function toUpper s><#return s?upperCase></#function>"; + // Lazy: + assertOutput( + functionDef + + "<#list xs?map(toUpper) as x>${x}<#sep>, </#list>", + testParam.result); + // Eager: + assertOutput( + functionDef + + "<#assign fxs = xs?map(toUpper)>" + + "${fxs?join(', ')}", + testParam.result); + } + } + + @Test + public void testFilterWithMethod() throws Exception { + for (TestParam testParam : TEST_PARAMS) { + addToDataModel("xs", testParam.list); + addToDataModel("obj", new MapperObject()); + // Lazy: + assertOutput( + "<#list xs?map(obj.toUpper) as x>${x}<#sep>, </#list>", + testParam.result); + // Eager: + assertOutput( + "<#assign fxs = xs?map(obj.toUpper)>" + + "${fxs?join(', ')}", + testParam.result); + } + } + + @Test + public void testWithNumberElements() throws Exception { + addToDataModel("xs", ImmutableList.of(1, 1.55, 3)); + addToDataModel("obj", new MapperObject()); + assertOutput( + "<#list xs?map(n -> n * 10) as x>${x}<#sep>, </#list>", + "10, 15.5, 30"); + assertOutput( + "<#function tenTimes n><#return n * 10></#function>" + + "<#list xs?map(tenTimes) as x>${x}<#sep>, </#list>", + "10, 15.5, 30"); + assertOutput( + "<#list xs?map(obj.tenTimes) as x>${x}<#sep>, </#list>", + "10, 15.5, 30"); + } + + @Test + public void testWithBeanElements() throws Exception { + addToDataModel("xs", ImmutableList.of(new User("a"), new User("b"), new User("c"))); + addToDataModel("obj", new MapperObject()); + assertOutput( + "<#list xs?map(user -> user.name) as x>${x}<#sep>, </#list>", + "a, b, c"); + assertOutput( + "<#function extractName user><#return user.name></#function>" + + "<#list xs?map(extractName) as x>${x}<#sep>, </#list>", + "a, b, c"); + assertOutput( + "<#list xs?map(obj.extractName) as x>${x}<#sep>, </#list>", + "a, b, c"); + } + + @Test + public void testBuiltInsThatAllowLazyEval() throws Exception { + assertOutput("" + + "<#assign s = ''>" + + "<#function tenTimes(x)><#assign s += '${x};'><#return x * 10></#function>" + + "${(1..3)?map(tenTimes)?first} ${s}", "10 1;"); + + assertOutput("" + + "<#assign s = ''>" + + "<#function tenTimes(x)><#assign s += '${x};'><#return x * 10></#function>" + + "${(1..3)?map(tenTimes)?seqContains(20)} ${s}", "true 1;2;"); + + assertOutput("" + + "<#assign s = ''>" + + "<#function tenTimes(x)><#assign s += '${x};'><#return x * 10></#function>" + + "${(1..3)?map(tenTimes)?seqIndexOf(20)} ${s}", "1 1;2;"); + + // For these this test can't check that there was no sequence built, but at least we know they are working: + assertOutput("${(1..3)?map(it -> it * 10)?min}", "10"); + assertOutput("${(1..3)?map(it -> it * 10)?max}", "30"); + assertOutput("${(1..3)?map(it -> it * 10)?join(', ')}", "10, 20, 30"); + } + + @Test + public void testLaziness() throws Exception { + // #list enables lazy evaluation: + assertOutput( + "" + + "<#assign s = ''>" + + "<#function tenTimes(x)><#assign s += '${x}->'><#return x * 10></#function>" + + "<#list (1..3)?map(tenTimes) as x>" + + "<#assign s += x>" + + "<#sep><#assign s += ', '>" + + "</#list>" + + "${s}", + "1->10, 2->20, 3->30"); + // Most other context causes eager behavior: + assertOutput( + "" + + "<#assign s = ''>" + + "<#function tenTimes(x)><#assign s += '${x}->'><#return x * 10></#function>" + + "<#assign xs = (1..3)?map(tenTimes)>" + + "<#list xs as x>" + + "<#assign s += x>" + + "<#sep><#assign s += ', '>" + + "</#list>" + + "${s}", + "1->2->3->10, 20, 30"); + + // ?map-s can be chained and all is "streaming": + assertOutput( + "" + + "<#assign s = ''>" + + "<#function tenTimes(x)><#assign s += '${x}->'><#return x * 10></#function>" + + "<#list (1..3)?map(tenTimes)?map(tenTimes)?map(tenTimes) as x>" + + "<#assign s += x>" + + "<#sep><#assign s += ', '>" + + "</#list>" + + "${s}", + "1->10->100->1000, 2->20->200->2000, 3->30->300->3000"); + + // Rest of the elements not consumed after #break: + assertOutput( + "" + + "<#assign s = ''>" + + "<#function tenTimes(x)><#assign s += '${x}->'><#return x * 10></#function>" + + "<#list (1..3)?map(tenTimes) as x>" + + "<#assign s += x>" + + "<#sep><#assign s += ', '>" + + "<#if x == 20><#break></#if>" + + "</#list>" + + "${s}", + "1->10, 2->20, "); + } + + @Test + public void testErrorMessages() { + assertErrorContains("${1?map(it -> it)}", TemplateException.class, + "sequence or collection", "number"); + assertErrorContains("${[]?map(1)}", TemplateException.class, + "method or function or lambda", "number"); + assertErrorContains("<#function f></#function>${['x']?map(f)}", TemplateException.class, + "Function", "0 parameters", "1"); + assertErrorContains("<#function f x y z></#function>${['x']?map(f)}", TemplateException.class, + "function", "parameter \"y\""); + assertErrorContains("<#function f x></#function>${['x']?map(f)}", TemplateException.class, + "null"); + assertErrorContains("${[]?map(() -> 1)}", ParseException.class, + "lambda", "1 parameter", "declared 0"); + assertErrorContains("${[]?map((i, j) -> 1)}", ParseException.class, + "lambda", "1 parameter", "declared 2"); + } + + public static class MapperObject { + public String toUpper(String s) { + return s.toUpperCase(); + } + public BigDecimal tenTimes(BigDecimal n) { + return n.movePointRight(1); + } + public String extractName(User user) { return user.getName(); } + } + + public static class User { + private final String name; + + public User(String name) { + this.name = name; + } + + public String getName() { + return name; + } + } + +}
