diff --git a/src/main/java/com/hubspot/jinjava/lib/tag/TagLibrary.java b/src/main/java/com/hubspot/jinjava/lib/tag/TagLibrary.java index 1a2155001..b6b8b7900 100644 --- a/src/main/java/com/hubspot/jinjava/lib/tag/TagLibrary.java +++ b/src/main/java/com/hubspot/jinjava/lib/tag/TagLibrary.java @@ -47,7 +47,8 @@ protected void registerDefaults() { RawTag.class, SetTag.class, UnlessTag.class, - DoTag.class + DoTag.class, + WithTag.class ); } diff --git a/src/main/java/com/hubspot/jinjava/lib/tag/WithTag.java b/src/main/java/com/hubspot/jinjava/lib/tag/WithTag.java new file mode 100644 index 000000000..218eb9c83 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/lib/tag/WithTag.java @@ -0,0 +1,288 @@ +/** + * Licensed 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 com.hubspot.jinjava.lib.tag; + +import com.hubspot.jinjava.doc.annotations.JinjavaDoc; +import com.hubspot.jinjava.doc.annotations.JinjavaHasCodeBody; +import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; +import com.hubspot.jinjava.doc.annotations.JinjavaTextMateSnippet; +import com.hubspot.jinjava.interpret.Context.Library; +import com.hubspot.jinjava.interpret.DisabledException; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.interpret.JinjavaInterpreter.InterpreterScopeClosable; +import com.hubspot.jinjava.interpret.TemplateError; +import com.hubspot.jinjava.interpret.TemplateError.ErrorItem; +import com.hubspot.jinjava.interpret.TemplateError.ErrorReason; +import com.hubspot.jinjava.interpret.TemplateError.ErrorType; +import com.hubspot.jinjava.interpret.TemplateSyntaxException; +import com.hubspot.jinjava.tree.Node; +import com.hubspot.jinjava.tree.TagNode; +import com.hubspot.jinjava.tree.parse.TagToken; +import com.hubspot.jinjava.util.LengthLimitingStringBuilder; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.regex.Pattern; +import org.apache.commons.lang3.StringUtils; + +/** + * Jinja's scoped assignment statement: + * + *
+ * {% with a = 1, b = foo.bar %}
+ *   {{ a }} {{ b }}
+ * {% endwith %}
+ * 
+ * + * The assigned names, and anything set inside the block, are only visible until + * {@code endwith}. As in Jinja, every value is evaluated in the enclosing scope + * before any name is bound, so {@code {% with a = 1, b = a %}} binds {@code b} to the + * outer {@code a}. + */ +@JinjavaDoc( + value = "Creates a new inner scope. Variables assigned in the with tag, or set inside its body, are not visible outside of it.", + snippets = { + @JinjavaSnippet( + code = "{% with name = \"my_job\", schedule = \"0 1 * * 0\" %}\n" + + " {% include \"cron_job.html\" %}\n" + + "{% endwith %}" + ), + @JinjavaSnippet( + desc = "A with tag without assignments just scopes the variables set inside it", + code = "{% with %}\n" + " {% set foo = 42 %}\n" + " {{ foo }}\n" + "{% endwith %}" + ), + } +) +@JinjavaHasCodeBody +@JinjavaTextMateSnippet(code = "{% with ${1:name} = ${2:value} %}\n$0\n{% endwith %}") +public class WithTag implements Tag { + + public static final String TAG_NAME = "with"; + + private static final long serialVersionUID = -2880947254236361217L; + private static final Pattern IDENTIFIER = Pattern.compile("[A-Za-z_][A-Za-z0-9_]*"); + + @Override + public String getName() { + return TAG_NAME; + } + + @Override + public boolean isRenderedInValidationMode() { + return true; + } + + @Override + public String interpret(TagNode tagNode, JinjavaInterpreter interpreter) { + Assignments assignments = parseAssignments(tagNode); + if (!assignments.isEmpty() && isSetTagDisabled(interpreter)) { + // Assignments go through the set tag, so with must not become a way around + // a disabled set tag. + DisabledException e = new DisabledException(SetTag.TAG_NAME); + interpreter.addError( + new TemplateError( + ErrorType.FATAL, + ErrorReason.DISABLED, + ErrorItem.TAG, + e.getMessage(), + TAG_NAME, + tagNode.getLineNumber(), + tagNode.getStartPosition(), + e + ) + ); + return ""; + } + try (InterpreterScopeClosable c = interpreter.enterScope()) { + LengthLimitingStringBuilder result = new LengthLimitingStringBuilder( + interpreter.getConfig().getMaxOutputSize() + ); + if (!assignments.isEmpty()) { + // Delegate to the registered set tag so that a multi-target set evaluates + // every value before binding any name, and so that eager execution can + // preserve deferred assignments the same way it does for set. + result.append( + interpreter + .getContext() + .getTag(SetTag.TAG_NAME) + .interpret(buildSetNode(tagNode, assignments, interpreter), interpreter) + ); + } + for (Node child : tagNode.getChildren()) { + result.append(child.render(interpreter)); + } + return result.toString(); + } + } + + private static boolean isSetTagDisabled(JinjavaInterpreter interpreter) { + Map> disabled = interpreter.getConfig().getDisabled(); + if (disabled == null) { + return false; + } + Set disabledTags = disabled.get(Library.TAG); + return disabledTags != null && disabledTags.contains(SetTag.TAG_NAME); + } + + private static TagNode buildSetNode( + TagNode tagNode, + Assignments assignments, + JinjavaInterpreter interpreter + ) { + TagToken withToken = (TagToken) tagNode.getMaster(); + String image = String.format( + "%s %s %s = %s %s", + withToken.getSymbols().getExpressionStartWithTag(), + SetTag.TAG_NAME, + String.join(", ", assignments.names), + String.join(", ", assignments.expressions), + withToken.getSymbols().getExpressionEndWithTag() + ); + TagToken setToken = new TagToken( + image, + withToken.getLineNumber(), + withToken.getStartPosition(), + withToken.getSymbols() + ); + return new TagNode( + interpreter.getContext().getTag(SetTag.TAG_NAME), + setToken, + withToken.getSymbols() + ); + } + + public static Assignments parseAssignments(TagNode tagNode) { + Assignments assignments = new Assignments(); + String helpers = tagNode.getHelpers(); + if (StringUtils.isBlank(helpers)) { + return assignments; + } + for (String part : splitTopLevel(helpers, ',')) { + int eqPos = findAssignmentOperator(part); + if (eqPos < 0) { + throw syntaxError( + tagNode, + "Tag 'with' expects assignments like 'name = value', got '" + part.trim() + "'" + ); + } + String name = part.substring(0, eqPos).trim(); + String expression = part.substring(eqPos + 1).trim(); + if (!IDENTIFIER.matcher(name).matches()) { + throw syntaxError(tagNode, "Tag 'with' can not assign to '" + name + "'"); + } + if (expression.isEmpty()) { + throw syntaxError(tagNode, "Tag 'with' requires a value for '" + name + "'"); + } + if (assignments.names.contains(name)) { + throw syntaxError(tagNode, "Tag 'with' assigns '" + name + "' more than once"); + } + assignments.names.add(name); + assignments.expressions.add(expression); + } + return assignments; + } + + private static TemplateSyntaxException syntaxError(TagNode tagNode, String message) { + return new TemplateSyntaxException( + tagNode.getMaster().getImage(), + message, + tagNode.getLineNumber(), + tagNode.getStartPosition() + ); + } + + /** + * Splits on the separator when it is outside of quotes, parentheses, brackets + * and braces. + */ + static List splitTopLevel(String input, char separator) { + List parts = new ArrayList<>(); + int depth = 0; + char quote = 0; + int start = 0; + for (int i = 0; i < input.length(); i++) { + char ch = input.charAt(i); + if (quote != 0) { + if (ch == '\\') { + i++; + } else if (ch == quote) { + quote = 0; + } + continue; + } + if (ch == '\'' || ch == '"') { + quote = ch; + } else if (ch == '(' || ch == '[' || ch == '{') { + depth++; + } else if (ch == ')' || ch == ']' || ch == '}') { + depth--; + } else if (ch == separator && depth == 0) { + parts.add(input.substring(start, i)); + start = i + 1; + } + } + parts.add(input.substring(start)); + return parts; + } + + /** + * Index of the first '=' that is an assignment, not part of ==, !=, <= or >=. + */ + private static int findAssignmentOperator(String part) { + char quote = 0; + for (int i = 0; i < part.length(); i++) { + char ch = part.charAt(i); + if (quote != 0) { + if (ch == '\\') { + i++; + } else if (ch == quote) { + quote = 0; + } + continue; + } + if (ch == '\'' || ch == '"') { + quote = ch; + } else if (ch == '=') { + char prev = i > 0 ? part.charAt(i - 1) : 0; + char next = i + 1 < part.length() ? part.charAt(i + 1) : 0; + if (next != '=' && prev != '=' && prev != '!' && prev != '<' && prev != '>') { + return i; + } + if (next == '=') { + i++; + } + } + } + return -1; + } + + public static class Assignments { + + private final List names = new ArrayList<>(); + private final List expressions = new ArrayList<>(); + + public List getNames() { + return names; + } + + public List getExpressions() { + return expressions; + } + + public boolean isEmpty() { + return names.isEmpty(); + } + } +} diff --git a/src/main/java/com/hubspot/jinjava/lib/tag/eager/EagerTagFactory.java b/src/main/java/com/hubspot/jinjava/lib/tag/eager/EagerTagFactory.java index af6b354ae..28149d804 100644 --- a/src/main/java/com/hubspot/jinjava/lib/tag/eager/EagerTagFactory.java +++ b/src/main/java/com/hubspot/jinjava/lib/tag/eager/EagerTagFactory.java @@ -24,6 +24,7 @@ import com.hubspot.jinjava.lib.tag.SetTag; import com.hubspot.jinjava.lib.tag.Tag; import com.hubspot.jinjava.lib.tag.UnlessTag; +import com.hubspot.jinjava.lib.tag.WithTag; import java.util.Map; import java.util.Optional; import java.util.Set; @@ -46,6 +47,7 @@ public class EagerTagFactory { .put(UnlessTag.class, EagerUnlessTag.class) .put(CallTag.class, EagerCallTag.class) .put(ContinueTag.class, EagerContinueTag.class) + .put(WithTag.class, EagerWithTag.class) .build(); // These classes don't need an eager decorator. public static final Set> TAG_CLASSES_TO_SKIP = ImmutableSet diff --git a/src/main/java/com/hubspot/jinjava/lib/tag/eager/EagerWithTag.java b/src/main/java/com/hubspot/jinjava/lib/tag/eager/EagerWithTag.java new file mode 100644 index 000000000..41722ae84 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/lib/tag/eager/EagerWithTag.java @@ -0,0 +1,106 @@ +/** + * Licensed 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 com.hubspot.jinjava.lib.tag.eager; + +import com.google.common.annotations.Beta; +import com.hubspot.jinjava.interpret.DeferredValueException; +import com.hubspot.jinjava.interpret.InterpretException; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.interpret.TemplateSyntaxException; +import com.hubspot.jinjava.lib.tag.WithTag; +import com.hubspot.jinjava.tree.TagNode; +import com.hubspot.jinjava.util.EagerContextWatcher; +import com.hubspot.jinjava.util.EagerExpressionResolver.EagerExpressionResult; +import com.hubspot.jinjava.util.EagerExpressionResolver.EagerExpressionResult.ResolutionState; +import com.hubspot.jinjava.util.EagerReconstructionUtils; + +/** + * Eager version of {@link WithTag}. When everything inside the with block resolves, + * the block is replaced by its output. When something is deferred, the output keeps + * the deferred assignments (as a set tag) and the body inside a child scope, so the + * names assigned by the with tag do not leak once the template is rendered later. + */ +@Beta +public class EagerWithTag extends EagerTagDecorator { + + public EagerWithTag() { + super(new WithTag()); + } + + public EagerWithTag(WithTag withTag) { + super(withTag); + } + + @Override + public String innerInterpret(TagNode tagNode, JinjavaInterpreter interpreter) { + // Surface syntax errors directly instead of trying to reconstruct the tag. + WithTag.parseAssignments(tagNode); + EagerExecutionResult result = renderInChildContext(tagNode, interpreter, false); + if (result.getResult().getResolutionState() == ResolutionState.NONE) { + EagerReconstructionUtils.resetSpeculativeBindings(interpreter, result); + throw new DeferredValueException(result.getResult().toString(true)); + } + if (result.getResult().isFullyResolved()) { + return result.getResult().toString(true); + } + return ( + result.getPrefixToPreserveState() + + EagerReconstructionUtils.wrapInChildScope( + result.getResult().toString(true), + interpreter + ) + ); + } + + @Override + public String eagerInterpret( + TagNode tagNode, + JinjavaInterpreter interpreter, + InterpretException e + ) { + if (e instanceof TemplateSyntaxException) { + throw e; + } + EagerExecutionResult result = renderInChildContext(tagNode, interpreter, true); + if (result.getResult().getResolutionState() == ResolutionState.NONE) { + throw new DeferredValueException(result.getResult().toString(true)); + } + return ( + result.getPrefixToPreserveState() + + EagerReconstructionUtils.wrapInChildScope( + result.getResult().toString(true), + interpreter + ) + ); + } + + private EagerExecutionResult renderInChildContext( + TagNode tagNode, + JinjavaInterpreter interpreter, + boolean forceDeferredExecutionMode + ) { + return EagerContextWatcher.executeInChildContext( + eagerInterpreter -> + EagerExpressionResult.fromSupplier( + () -> getTag().interpret(tagNode, eagerInterpreter), + eagerInterpreter + ), + interpreter, + EagerContextWatcher.EagerChildContextConfig + .newBuilder() + .withForceDeferredExecutionMode(forceDeferredExecutionMode) + .build() + ); + } +} diff --git a/src/test/java/com/hubspot/jinjava/lib/tag/WithTagDisabledSetTest.java b/src/test/java/com/hubspot/jinjava/lib/tag/WithTagDisabledSetTest.java new file mode 100644 index 000000000..0edfc349a --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/lib/tag/WithTagDisabledSetTest.java @@ -0,0 +1,61 @@ +/** + * Licensed 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 com.hubspot.jinjava.lib.tag; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSet; +import com.hubspot.jinjava.BaseJinjavaTest; +import com.hubspot.jinjava.Jinjava; +import com.hubspot.jinjava.interpret.Context; +import com.hubspot.jinjava.interpret.RenderResult; +import com.hubspot.jinjava.interpret.TemplateError; +import com.hubspot.jinjava.interpret.TemplateError.ErrorReason; +import com.hubspot.jinjava.mode.EagerExecutionMode; +import com.hubspot.jinjava.mode.ExecutionMode; +import java.util.HashMap; +import org.junit.Test; + +public class WithTagDisabledSetTest { + + @Test + public void itDoesNotAssignWhenSetTagIsDisabled() { + assertSetIsNotBypassed(BaseJinjavaTest.newConfigBuilder().build().getExecutionMode()); + } + + @Test + public void itDoesNotAssignWhenSetTagIsDisabledInEagerMode() { + assertSetIsNotBypassed(EagerExecutionMode.instance()); + } + + private void assertSetIsNotBypassed(ExecutionMode executionMode) { + // with assigns through set, so disabling set must also cover with + Jinjava jinjava = new Jinjava( + BaseJinjavaTest + .newConfigBuilder() + .withExecutionMode(executionMode) + .withDisabled(ImmutableMap.of(Context.Library.TAG, ImmutableSet.of("set"))) + .build() + ); + RenderResult result = jinjava.renderForResult( + "{% with a = 1 %}[{{ a }}]{% endwith %}{% with %}ok{% endwith %}", + new HashMap<>() + ); + assertThat(result.getOutput()).isEqualTo("ok"); + assertThat(result.getErrors()) + .extracting(TemplateError::getReason) + .containsExactly(ErrorReason.DISABLED); + } +} diff --git a/src/test/java/com/hubspot/jinjava/lib/tag/WithTagTest.java b/src/test/java/com/hubspot/jinjava/lib/tag/WithTagTest.java new file mode 100644 index 000000000..eb86bc6d3 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/lib/tag/WithTagTest.java @@ -0,0 +1,135 @@ +/** + * Licensed 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 com.hubspot.jinjava.lib.tag; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.hubspot.jinjava.BaseInterpretingTest; +import com.hubspot.jinjava.interpret.RenderResult; +import com.hubspot.jinjava.interpret.TemplateError; +import com.hubspot.jinjava.interpret.TemplateError.ErrorReason; +import com.hubspot.jinjava.interpret.TemplateSyntaxException; +import java.util.HashMap; +import org.junit.Test; + +public class WithTagTest extends BaseInterpretingTest { + + @Test + public void itAssignsVariablesForTheBlockOnly() { + String template = + "{% with a = 1, b = 'x' %}{{ a }}{{ b }}{% endwith %}[{{ a }}{{ b }}]"; + assertThat(interpreter.render(template)).isEqualTo("1x[]"); + assertThat(interpreter.getErrors()).isEmpty(); + } + + @Test + public void itSupportsTheIssueExample() { + String template = + "{% macro cron(name, schedule, deadline) %}{{ name }}|{{ schedule }}|{{ deadline }}{% endmacro %}" + + "{% with jobname=\"my_job\", schedule=\"0 1 * * 0\", deadline=300 %}" + + "{{ cron(jobname, schedule, deadline) }}" + + "{% endwith %}"; + assertThat(interpreter.render(template)).isEqualTo("my_job|0 1 * * 0|300"); + assertThat(interpreter.getErrors()).isEmpty(); + } + + @Test + public void itShadowsAndRestoresOuterVariables() { + String template = "{% set x = 1 %}{% with x = 2 %}{{ x }}{% endwith %}{{ x }}"; + assertThat(interpreter.render(template)).isEqualTo("21"); + } + + @Test + public void itEvaluatesAllValuesInTheOuterScope() { + // Matches Jinja: b sees the outer a, not the a assigned by the same tag + String template = + "{% set a = 'outer' %}{% with a = 'inner', b = a %}{{ a }} {{ b }}{% endwith %} {{ a }}"; + assertThat(interpreter.render(template)).isEqualTo("inner outer outer"); + } + + @Test + public void itScopesSetTagsInsideTheBody() { + String template = + "{% set kept = 'outer' %}{% with %}{% set foo = 42 %}{% set kept = 'inner' %}{{ foo }} {{ kept }}{% endwith %} [{{ foo }}] {{ kept }}"; + assertThat(interpreter.render(template)).isEqualTo("42 inner [] outer"); + } + + @Test + public void itHandlesCommasAndOperatorsInsideValues() { + String template = + "{% with items = [1, 2, 3], same = (1 == 1), not_same = 1 != 1, small = 1 <= 2, " + + "m = {'k': 'a,b'}, s = \"x=y, z\", f = 'a,b'|replace(',', ';') %}" + + "{{ items|length }} {{ same }} {{ not_same }} {{ small }} {{ m.k }} {{ s }} {{ f }}" + + "{% endwith %}"; + assertThat(interpreter.render(template)) + .isEqualTo("3 true false true a,b x=y, z a;b"); + assertThat(interpreter.getErrors()).isEmpty(); + } + + @Test + public void itSupportsNestedWithBlocks() { + String template = + "{% with a = 1 %}{% with b = a + 1 %}{{ a }}{{ b }}{% endwith %}{{ a }}[{{ b }}]{% endwith %}"; + assertThat(interpreter.render(template)).isEqualTo("121[]"); + } + + @Test + public void itWorksWithLoopVariables() { + String template = + "{% for i in [1, 2] %}{% with j = i * 10 %}{{ j }},{% endwith %}{% endfor %}[{{ j }}]"; + assertThat(interpreter.render(template)).isEqualTo("10,20,[]"); + } + + @Test + public void itThrowsSyntaxErrorForInvalidTarget() { + assertThatThrownBy(() -> interpreter.render("{% with a.b = 1 %}{{ a }}{% endwith %}")) + .isInstanceOf(TemplateSyntaxException.class) + .hasMessageContaining("can not assign to 'a.b'"); + } + + @Test + public void itThrowsSyntaxErrorForMissingAssignment() { + assertThatThrownBy(() -> interpreter.render("{% with a %}{{ a }}{% endwith %}")) + .isInstanceOf(TemplateSyntaxException.class) + .hasMessageContaining("expects assignments"); + } + + @Test + public void itThrowsSyntaxErrorForMissingValue() { + assertThatThrownBy(() -> interpreter.render("{% with a = %}{{ a }}{% endwith %}")) + .isInstanceOf(TemplateSyntaxException.class) + .hasMessageContaining("requires a value for 'a'"); + } + + @Test + public void itThrowsSyntaxErrorForDuplicateNames() { + assertThatThrownBy(() -> + interpreter.render("{% with a = 1, a = 2 %}{{ a }}{% endwith %}") + ) + .isInstanceOf(TemplateSyntaxException.class) + .hasMessageContaining("more than once"); + } + + @Test + public void itReportsSyntaxErrorsWhenRenderingForResult() { + RenderResult result = jinjava.renderForResult( + "{% with 1 = 2 %}{% endwith %}", + new HashMap<>() + ); + assertThat(result.getErrors()) + .extracting(TemplateError::getReason) + .containsExactly(ErrorReason.SYNTAX_ERROR); + } +} diff --git a/src/test/java/com/hubspot/jinjava/lib/tag/eager/EagerWithTagTest.java b/src/test/java/com/hubspot/jinjava/lib/tag/eager/EagerWithTagTest.java new file mode 100644 index 000000000..9ba99d840 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/lib/tag/eager/EagerWithTagTest.java @@ -0,0 +1,121 @@ +/** + * Licensed 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 com.hubspot.jinjava.lib.tag.eager; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.google.common.collect.ImmutableMap; +import com.hubspot.jinjava.BaseJinjavaTest; +import com.hubspot.jinjava.Jinjava; +import com.hubspot.jinjava.interpret.DeferredValue; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.lib.tag.WithTagTest; +import com.hubspot.jinjava.mode.EagerExecutionMode; +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.Map; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +public class EagerWithTagTest extends WithTagTest { + + @Before + public void eagerSetup() { + interpreter = + new JinjavaInterpreter( + jinjava, + context, + BaseJinjavaTest + .newConfigBuilder() + .withExecutionMode(EagerExecutionMode.instance()) + .build() + ); + context.registerTag(new EagerWithTag()); + context.put("deferred", DeferredValue.instance()); + JinjavaInterpreter.pushCurrent(interpreter); + } + + @After + public void teardown() { + JinjavaInterpreter.popCurrent(); + } + + @Test + public void itFullyResolvesWhenNothingIsDeferred() { + String output = interpreter.render( + "{% with a = 1, b = [a, 2] %}{{ b|join(',') }}{% endwith %}" + ); + assertThat(output).isEqualTo(",2"); + assertThat(context.getDeferredTokens()).isEmpty(); + } + + @Test + public void itPreservesDeferredAssignmentsInAChildScope() { + String output = interpreter.render( + "{% with a = deferred, b = 2 %}{{ a }} {{ b }}{% endwith %}[{{ a }}{{ b }}]" + ); + assertThat(output).doesNotContain("with"); + assertThat(output).contains("__ignored__"); + assertThat(renderSecondPass(output, ImmutableMap.of("deferred", "D"))) + .isEqualTo("D 2[]"); + } + + @Test + public void itResolvesAssignedValuesInsideDeferredBlocks() { + String output = interpreter.render( + "{% with a = 'x' %}{% if deferred %}{{ a }}{% endif %}{% endwith %}[{{ a }}]" + ); + assertThat(renderSecondPass(output, ImmutableMap.of("deferred", true))) + .isEqualTo("x[]"); + assertThat(renderSecondPass(output, ImmutableMap.of("deferred", false))) + .isEqualTo("[]"); + } + + @Test + public void itKeepsDeferredSetTagsInsideTheBodyScoped() { + String output = interpreter.render( + "{% set foo = 'outer' %}{% with %}{% set foo = deferred %}{{ foo }}{% endwith %} {{ foo }}" + ); + assertThat(renderSecondPass(output, ImmutableMap.of("deferred", "inner"))) + .isEqualTo("inner outer"); + } + + @Test + public void itEvaluatesDeferredValuesInTheOuterScope() { + String output = interpreter.render( + "{% set a = 'outer' %}{% with a = deferred, b = a %}{{ a }} {{ b }}{% endwith %}" + ); + assertThat(renderSecondPass(output, ImmutableMap.of("deferred", "inner"))) + .isEqualTo("inner outer"); + } + + private String renderSecondPass(String template, Map bindings) { + // Render the eager output the way it would be rendered later: with no current + // interpreter, so nothing is inherited from the eager context. + Deque pushed = new ArrayDeque<>(); + while (JinjavaInterpreter.getCurrent() != null) { + pushed.push(JinjavaInterpreter.getCurrent()); + JinjavaInterpreter.popCurrent(); + } + try { + return new Jinjava(BaseJinjavaTest.newConfigBuilder().build()) + .render(template, bindings); + } finally { + while (!pushed.isEmpty()) { + JinjavaInterpreter.pushCurrent(pushed.pop()); + } + } + } +}