Skip to content

Commit

Permalink
Merge pull request #1219 from ccutrer/loop-extensions
Browse files Browse the repository at this point in the history
Implement jinja2.ext.loopcontrols extensions
  • Loading branch information
jasmith-hs authored Dec 6, 2024
2 parents 703b11c + 69b70ba commit 1996571
Show file tree
Hide file tree
Showing 10 changed files with 253 additions and 4 deletions.
Original file line number Diff line number Diff line change
@@ -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);
}
}
51 changes: 51 additions & 0 deletions src/main/java/com/hubspot/jinjava/lib/tag/BreakTag.java
Original file line number Diff line number Diff line change
@@ -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;
}
}
49 changes: 49 additions & 0 deletions src/main/java/com/hubspot/jinjava/lib/tag/ContinueTag.java
Original file line number Diff line number Diff line change
@@ -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;
}
}
6 changes: 5 additions & 1 deletion src/main/java/com/hubspot/jinjava/lib/tag/ForTag.java
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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(),
Expand Down
2 changes: 2 additions & 0 deletions src/main/java/com/hubspot/jinjava/lib/tag/TagLibrary.java
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@ protected void registerDefaults() {
registerClasses(
AutoEscapeTag.class,
BlockTag.class,
BreakTag.class,
CallTag.class,
ContinueTag.class,
CycleTag.class,
ElseTag.class,
ElseIfTag.class,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> loopVars = getTag()
.getLoopVarsAndExpression((TagToken) tagNode.getMaster())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -211,7 +212,7 @@ private static Map<String, Object> 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() ||
Expand Down
26 changes: 26 additions & 0 deletions src/main/java/com/hubspot/jinjava/util/ForLoop.java
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ public class ForLoop implements Iterator<Object> {
private int length = NULL_VALUE;
private boolean first = true;
private boolean last;
private boolean continued;
private boolean broken;

private int depth;

Expand All @@ -45,6 +47,8 @@ public ForLoop(Iterator<?> ite, int len) {
last = false;
}
it = ite;
continued = false;
broken = false;
}

public ForLoop(Iterator<?> ite) {
Expand All @@ -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++;
Expand Down Expand Up @@ -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();
}

Expand Down
51 changes: 51 additions & 0 deletions src/test/java/com/hubspot/jinjava/lib/tag/BreakTagTest.java
Original file line number Diff line number Diff line change
@@ -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");
}
}
51 changes: 51 additions & 0 deletions src/test/java/com/hubspot/jinjava/lib/tag/ContinueTagTest.java
Original file line number Diff line number Diff line change
@@ -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");
}
}

0 comments on commit 1996571

Please sign in to comment.