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());
+ }
+ }
+ }
+}