From 69b70ba81863a87ec14c052e2193a5fc7947756d Mon Sep 17 00:00:00 2001 From: Cody Cutrer Date: Sat, 23 Nov 2024 12:49:05 -0700 Subject: [PATCH] Implement jinja2.ext.loopcontrols extensions `break` and `continue` in for loops. See https://jinja.palletsprojects.com/en/stable/extensions/#loop-controls --- .../jinjava/interpret/NotInLoopException.java | 14 +++++ .../com/hubspot/jinjava/lib/tag/BreakTag.java | 51 +++++++++++++++++++ .../hubspot/jinjava/lib/tag/ContinueTag.java | 49 ++++++++++++++++++ .../com/hubspot/jinjava/lib/tag/ForTag.java | 6 ++- .../hubspot/jinjava/lib/tag/TagLibrary.java | 2 + .../jinjava/lib/tag/eager/EagerForTag.java | 4 +- .../jinjava/util/EagerContextWatcher.java | 3 +- .../com/hubspot/jinjava/util/ForLoop.java | 26 ++++++++++ .../hubspot/jinjava/lib/tag/BreakTagTest.java | 51 +++++++++++++++++++ .../jinjava/lib/tag/ContinueTagTest.java | 51 +++++++++++++++++++ 10 files changed, 253 insertions(+), 4 deletions(-) create mode 100644 src/main/java/com/hubspot/jinjava/interpret/NotInLoopException.java create mode 100644 src/main/java/com/hubspot/jinjava/lib/tag/BreakTag.java create mode 100644 src/main/java/com/hubspot/jinjava/lib/tag/ContinueTag.java create mode 100644 src/test/java/com/hubspot/jinjava/lib/tag/BreakTagTest.java create mode 100644 src/test/java/com/hubspot/jinjava/lib/tag/ContinueTagTest.java diff --git a/src/main/java/com/hubspot/jinjava/interpret/NotInLoopException.java b/src/main/java/com/hubspot/jinjava/interpret/NotInLoopException.java new file mode 100644 index 000000000..d4bd77eb9 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/interpret/NotInLoopException.java @@ -0,0 +1,14 @@ +package com.hubspot.jinjava.interpret; + +/** + * Exception thrown when `continue` or `break` is called outside of a loop + */ +public class NotInLoopException extends InterpretException { + + public static final String MESSAGE_PREFIX = "`"; + public static final String MESSAGE_SUFFIX = "` called while not in a for loop"; + + public NotInLoopException(String tagName) { + super(MESSAGE_PREFIX + tagName + MESSAGE_SUFFIX); + } +} diff --git a/src/main/java/com/hubspot/jinjava/lib/tag/BreakTag.java b/src/main/java/com/hubspot/jinjava/lib/tag/BreakTag.java new file mode 100644 index 000000000..aaf6a6cba --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/lib/tag/BreakTag.java @@ -0,0 +1,51 @@ +package com.hubspot.jinjava.lib.tag; + +import com.hubspot.jinjava.doc.annotations.JinjavaDoc; +import com.hubspot.jinjava.doc.annotations.JinjavaTextMateSnippet; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.interpret.NotInLoopException; +import com.hubspot.jinjava.tree.TagNode; +import com.hubspot.jinjava.util.ForLoop; + +/** + * Implements the common loopcontrol `continue`, as in the jinja2.ext.loopcontrols extension + * @author ccutrer + */ + +@JinjavaDoc( + value = "Stops executing the current for loop, including any further iterations" +) +@JinjavaTextMateSnippet( + code = "{% for item in [1, 2, 3, 4] %}{% if item > 2 == 0 %}{% break %}{% endif %}{{ item }}{% endfor %}" +) +public class BreakTag implements Tag { + + public static final String TAG_NAME = "break"; + + @Override + public String getName() { + return TAG_NAME; + } + + @Override + public String interpret(TagNode tagNode, JinjavaInterpreter interpreter) { + Object loop = interpreter.getContext().get(ForTag.LOOP); + if (loop instanceof ForLoop) { + ForLoop forLoop = (ForLoop) loop; + forLoop.doBreak(); + } else { + throw new NotInLoopException(TAG_NAME); + } + return ""; + } + + @Override + public String getEndTagName() { + return null; + } + + @Override + public boolean isRenderedInValidationMode() { + return true; + } +} diff --git a/src/main/java/com/hubspot/jinjava/lib/tag/ContinueTag.java b/src/main/java/com/hubspot/jinjava/lib/tag/ContinueTag.java new file mode 100644 index 000000000..d6250843a --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/lib/tag/ContinueTag.java @@ -0,0 +1,49 @@ +package com.hubspot.jinjava.lib.tag; + +import com.hubspot.jinjava.doc.annotations.JinjavaDoc; +import com.hubspot.jinjava.doc.annotations.JinjavaTextMateSnippet; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.interpret.NotInLoopException; +import com.hubspot.jinjava.tree.TagNode; +import com.hubspot.jinjava.util.ForLoop; + +/** + * Implements the common loopcontrol `continue`, as in the jinja2.ext.loopcontrols extension + * @author ccutrer + */ + +@JinjavaDoc(value = "Stops executing the current iteration of the current for loop") +@JinjavaTextMateSnippet( + code = "{% for item in [1, 2, 3, 4] %}{% if item % 2 == 0 %}{% continue %}{% endif %}{{ item }}{% endfor %}" +) +public class ContinueTag implements Tag { + + public static final String TAG_NAME = "continue"; + + @Override + public String getName() { + return TAG_NAME; + } + + @Override + public String interpret(TagNode tagNode, JinjavaInterpreter interpreter) { + Object loop = interpreter.getContext().get(ForTag.LOOP); + if (loop instanceof ForLoop) { + ForLoop forLoop = (ForLoop) loop; + forLoop.doContinue(); + } else { + throw new NotInLoopException(TAG_NAME); + } + return ""; + } + + @Override + public String getEndTagName() { + return null; + } + + @Override + public boolean isRenderedInValidationMode() { + return true; + } +} diff --git a/src/main/java/com/hubspot/jinjava/lib/tag/ForTag.java b/src/main/java/com/hubspot/jinjava/lib/tag/ForTag.java index 622c014f0..abbcbd8ca 100644 --- a/src/main/java/com/hubspot/jinjava/lib/tag/ForTag.java +++ b/src/main/java/com/hubspot/jinjava/lib/tag/ForTag.java @@ -277,6 +277,10 @@ public String renderForCollection( interpreter.addError(TemplateError.fromOutputTooBigException(e)); return checkLoopVariable(interpreter, buff); } + // continue in the body of the loop; ignore the rest of the body + if (loop.isContinued()) { + break; + } } } if ( @@ -297,7 +301,7 @@ private String checkLoopVariable( JinjavaInterpreter interpreter, LengthLimitingStringBuilder buff ) { - if (interpreter.getContext().get("loop") instanceof DeferredValue) { + if (interpreter.getContext().get(LOOP) instanceof DeferredValue) { throw new DeferredValueException( "loop variable deferred", interpreter.getLineNumber(), 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 15312cd79..1a2155001 100644 --- a/src/main/java/com/hubspot/jinjava/lib/tag/TagLibrary.java +++ b/src/main/java/com/hubspot/jinjava/lib/tag/TagLibrary.java @@ -29,7 +29,9 @@ protected void registerDefaults() { registerClasses( AutoEscapeTag.class, BlockTag.class, + BreakTag.class, CallTag.class, + ContinueTag.class, CycleTag.class, ElseTag.class, ElseIfTag.class, diff --git a/src/main/java/com/hubspot/jinjava/lib/tag/eager/EagerForTag.java b/src/main/java/com/hubspot/jinjava/lib/tag/eager/EagerForTag.java index c16fb1c6b..fbd775782 100644 --- a/src/main/java/com/hubspot/jinjava/lib/tag/eager/EagerForTag.java +++ b/src/main/java/com/hubspot/jinjava/lib/tag/eager/EagerForTag.java @@ -179,8 +179,8 @@ private EagerExecutionResult runLoopOnce( ) { return EagerContextWatcher.executeInChildContext( eagerInterpreter -> { - if (!(eagerInterpreter.getContext().get("loop") instanceof DeferredValue)) { - eagerInterpreter.getContext().put("loop", DeferredValue.instance()); + if (!(eagerInterpreter.getContext().get(ForTag.LOOP) instanceof DeferredValue)) { + eagerInterpreter.getContext().put(ForTag.LOOP, DeferredValue.instance()); } List loopVars = getTag() .getLoopVarsAndExpression((TagToken) tagNode.getMaster()) diff --git a/src/main/java/com/hubspot/jinjava/util/EagerContextWatcher.java b/src/main/java/com/hubspot/jinjava/util/EagerContextWatcher.java index 5a3e8b3d5..83ed8f674 100644 --- a/src/main/java/com/hubspot/jinjava/util/EagerContextWatcher.java +++ b/src/main/java/com/hubspot/jinjava/util/EagerContextWatcher.java @@ -11,6 +11,7 @@ import com.hubspot.jinjava.interpret.MetaContextVariables; import com.hubspot.jinjava.interpret.OneTimeReconstructible; import com.hubspot.jinjava.interpret.RevertibleObject; +import com.hubspot.jinjava.lib.tag.ForTag; import com.hubspot.jinjava.lib.tag.eager.EagerExecutionResult; import com.hubspot.jinjava.objects.collections.PyList; import com.hubspot.jinjava.objects.collections.PyMap; @@ -211,7 +212,7 @@ private static Map getBasicSpeculativeBindings( ) ) .filter(entry -> !ignoredKeys.contains(entry.getKey())) - .filter(entry -> !"loop".equals(entry.getKey())) + .filter(entry -> !ForTag.LOOP.equals(entry.getKey())) .map(entry -> { if ( eagerExecutionResult.getResult().isFullyResolved() || diff --git a/src/main/java/com/hubspot/jinjava/util/ForLoop.java b/src/main/java/com/hubspot/jinjava/util/ForLoop.java index 071a2f887..e233e7b95 100644 --- a/src/main/java/com/hubspot/jinjava/util/ForLoop.java +++ b/src/main/java/com/hubspot/jinjava/util/ForLoop.java @@ -28,6 +28,8 @@ public class ForLoop implements Iterator { private int length = NULL_VALUE; private boolean first = true; private boolean last; + private boolean continued; + private boolean broken; private int depth; @@ -45,6 +47,8 @@ public ForLoop(Iterator ite, int len) { last = false; } it = ite; + continued = false; + broken = false; } public ForLoop(Iterator ite) { @@ -57,10 +61,16 @@ public ForLoop(Iterator ite) { revcounter = 2; last = true; } + continued = false; + broken = false; } @Override public Object next() { + if (broken) { + return null; + } + continued = false; Object res; if (it.hasNext()) { index++; @@ -129,8 +139,24 @@ public boolean isLast() { return last; } + public boolean isContinued() { + return continued; + } + + public void doContinue() { + continued = true; + } + + public void doBreak() { + continued = true; + broken = true; + } + @Override public boolean hasNext() { + if (broken) { + return false; + } return it.hasNext(); } diff --git a/src/test/java/com/hubspot/jinjava/lib/tag/BreakTagTest.java b/src/test/java/com/hubspot/jinjava/lib/tag/BreakTagTest.java new file mode 100644 index 000000000..1d9312f7e --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/lib/tag/BreakTagTest.java @@ -0,0 +1,51 @@ +package com.hubspot.jinjava.lib.tag; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.hubspot.jinjava.BaseInterpretingTest; +import com.hubspot.jinjava.interpret.RenderResult; +import com.hubspot.jinjava.interpret.TemplateError; +import org.junit.Test; + +public class BreakTagTest extends BaseInterpretingTest { + + @Test + public void testBreak() { + String template = + "{% for item in [1, 2, 3, 4] %}{% if item > 2 %}{% break %}{% endif %}{{ item }}{% endfor %}"; + + RenderResult rendered = jinjava.renderForResult(template, context); + assertThat(rendered.getOutput()).isEqualTo("12"); + } + + @Test + public void testNestedBreak() { + String template = + "{% for item in [1, 2, 3, 4] %}{% for item2 in [5, 6, 7] %}{% break %}{{ item2 }}{% endfor %}{{ item }}{% endfor %}"; + + RenderResult rendered = jinjava.renderForResult(template, context); + assertThat(rendered.getOutput()).isEqualTo("1234"); + } + + @Test + public void testBreakWithEarlierContent() { + String template = + "{% for item in [1, 2, 3, 4] %}{{ item }}{% if item > 2 %}{% break %}{% endif %}{{ item }}{% endfor %}"; + + RenderResult rendered = jinjava.renderForResult(template, context); + assertThat(rendered.getOutput()).isEqualTo("11223"); + } + + @Test + public void testBreakOutOfContext() { + String template = "{% break %}"; + + RenderResult rendered = jinjava.renderForResult(template, context); + assertThat(rendered.getOutput()).isEqualTo(""); + assertThat(rendered.getErrors()).hasSize(1); + assertThat(rendered.getErrors().get(0).getSeverity()) + .isEqualTo(TemplateError.ErrorType.FATAL); + assertThat(rendered.getErrors().get(0).getMessage()) + .contains("NotInLoopException: `break` called while not in a for loop"); + } +} diff --git a/src/test/java/com/hubspot/jinjava/lib/tag/ContinueTagTest.java b/src/test/java/com/hubspot/jinjava/lib/tag/ContinueTagTest.java new file mode 100644 index 000000000..3b0ac69bc --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/lib/tag/ContinueTagTest.java @@ -0,0 +1,51 @@ +package com.hubspot.jinjava.lib.tag; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.hubspot.jinjava.BaseInterpretingTest; +import com.hubspot.jinjava.interpret.RenderResult; +import com.hubspot.jinjava.interpret.TemplateError; +import org.junit.Test; + +public class ContinueTagTest extends BaseInterpretingTest { + + @Test + public void testContinue() { + String template = + "{% for item in [1, 2, 3, 4] %}{% if item % 2 == 0 %}{% continue %}{% endif %}{{ item }}{% endfor %}"; + + RenderResult rendered = jinjava.renderForResult(template, context); + assertThat(rendered.getOutput()).isEqualTo("13"); + } + + @Test + public void testNestedContinue() { + String template = + "{% for item in [1, 2, 3, 4] %}{% for item2 in [5, 6, 7] %}{% continue %}{{ item2 }}{% endfor %}{{ item }}{% endfor %}"; + + RenderResult rendered = jinjava.renderForResult(template, context); + assertThat(rendered.getOutput()).isEqualTo("1234"); + } + + @Test + public void testContinueWithEarlierContent() { + String template = + "{% for item in [1, 2, 3, 4] %}{{ item }}{% if item % 2 == 0 %}{% continue %}{% endif %}{{ item }}{% endfor %}"; + + RenderResult rendered = jinjava.renderForResult(template, context); + assertThat(rendered.getOutput()).isEqualTo("112334"); + } + + @Test + public void testContinueOutOfContext() { + String template = "{% continue %}"; + + RenderResult rendered = jinjava.renderForResult(template, context); + assertThat(rendered.getOutput()).isEqualTo(""); + assertThat(rendered.getErrors()).hasSize(1); + assertThat(rendered.getErrors().get(0).getSeverity()) + .isEqualTo(TemplateError.ErrorType.FATAL); + assertThat(rendered.getErrors().get(0).getMessage()) + .contains("NotInLoopException: `continue` called while not in a for loop"); + } +}