Skip to content

Commit 69b70ba

Browse files
committed
Implement jinja2.ext.loopcontrols extensions
`break` and `continue` in for loops. See https://jinja.palletsprojects.com/en/stable/extensions/#loop-controls
1 parent c392aac commit 69b70ba

File tree

10 files changed

+253
-4
lines changed

10 files changed

+253
-4
lines changed
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package com.hubspot.jinjava.interpret;
2+
3+
/**
4+
* Exception thrown when `continue` or `break` is called outside of a loop
5+
*/
6+
public class NotInLoopException extends InterpretException {
7+
8+
public static final String MESSAGE_PREFIX = "`";
9+
public static final String MESSAGE_SUFFIX = "` called while not in a for loop";
10+
11+
public NotInLoopException(String tagName) {
12+
super(MESSAGE_PREFIX + tagName + MESSAGE_SUFFIX);
13+
}
14+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
package com.hubspot.jinjava.lib.tag;
2+
3+
import com.hubspot.jinjava.doc.annotations.JinjavaDoc;
4+
import com.hubspot.jinjava.doc.annotations.JinjavaTextMateSnippet;
5+
import com.hubspot.jinjava.interpret.JinjavaInterpreter;
6+
import com.hubspot.jinjava.interpret.NotInLoopException;
7+
import com.hubspot.jinjava.tree.TagNode;
8+
import com.hubspot.jinjava.util.ForLoop;
9+
10+
/**
11+
* Implements the common loopcontrol `continue`, as in the jinja2.ext.loopcontrols extension
12+
* @author ccutrer
13+
*/
14+
15+
@JinjavaDoc(
16+
value = "Stops executing the current for loop, including any further iterations"
17+
)
18+
@JinjavaTextMateSnippet(
19+
code = "{% for item in [1, 2, 3, 4] %}{% if item > 2 == 0 %}{% break %}{% endif %}{{ item }}{% endfor %}"
20+
)
21+
public class BreakTag implements Tag {
22+
23+
public static final String TAG_NAME = "break";
24+
25+
@Override
26+
public String getName() {
27+
return TAG_NAME;
28+
}
29+
30+
@Override
31+
public String interpret(TagNode tagNode, JinjavaInterpreter interpreter) {
32+
Object loop = interpreter.getContext().get(ForTag.LOOP);
33+
if (loop instanceof ForLoop) {
34+
ForLoop forLoop = (ForLoop) loop;
35+
forLoop.doBreak();
36+
} else {
37+
throw new NotInLoopException(TAG_NAME);
38+
}
39+
return "";
40+
}
41+
42+
@Override
43+
public String getEndTagName() {
44+
return null;
45+
}
46+
47+
@Override
48+
public boolean isRenderedInValidationMode() {
49+
return true;
50+
}
51+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package com.hubspot.jinjava.lib.tag;
2+
3+
import com.hubspot.jinjava.doc.annotations.JinjavaDoc;
4+
import com.hubspot.jinjava.doc.annotations.JinjavaTextMateSnippet;
5+
import com.hubspot.jinjava.interpret.JinjavaInterpreter;
6+
import com.hubspot.jinjava.interpret.NotInLoopException;
7+
import com.hubspot.jinjava.tree.TagNode;
8+
import com.hubspot.jinjava.util.ForLoop;
9+
10+
/**
11+
* Implements the common loopcontrol `continue`, as in the jinja2.ext.loopcontrols extension
12+
* @author ccutrer
13+
*/
14+
15+
@JinjavaDoc(value = "Stops executing the current iteration of the current for loop")
16+
@JinjavaTextMateSnippet(
17+
code = "{% for item in [1, 2, 3, 4] %}{% if item % 2 == 0 %}{% continue %}{% endif %}{{ item }}{% endfor %}"
18+
)
19+
public class ContinueTag implements Tag {
20+
21+
public static final String TAG_NAME = "continue";
22+
23+
@Override
24+
public String getName() {
25+
return TAG_NAME;
26+
}
27+
28+
@Override
29+
public String interpret(TagNode tagNode, JinjavaInterpreter interpreter) {
30+
Object loop = interpreter.getContext().get(ForTag.LOOP);
31+
if (loop instanceof ForLoop) {
32+
ForLoop forLoop = (ForLoop) loop;
33+
forLoop.doContinue();
34+
} else {
35+
throw new NotInLoopException(TAG_NAME);
36+
}
37+
return "";
38+
}
39+
40+
@Override
41+
public String getEndTagName() {
42+
return null;
43+
}
44+
45+
@Override
46+
public boolean isRenderedInValidationMode() {
47+
return true;
48+
}
49+
}

src/main/java/com/hubspot/jinjava/lib/tag/ForTag.java

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,10 @@ public String renderForCollection(
277277
interpreter.addError(TemplateError.fromOutputTooBigException(e));
278278
return checkLoopVariable(interpreter, buff);
279279
}
280+
// continue in the body of the loop; ignore the rest of the body
281+
if (loop.isContinued()) {
282+
break;
283+
}
280284
}
281285
}
282286
if (
@@ -297,7 +301,7 @@ private String checkLoopVariable(
297301
JinjavaInterpreter interpreter,
298302
LengthLimitingStringBuilder buff
299303
) {
300-
if (interpreter.getContext().get("loop") instanceof DeferredValue) {
304+
if (interpreter.getContext().get(LOOP) instanceof DeferredValue) {
301305
throw new DeferredValueException(
302306
"loop variable deferred",
303307
interpreter.getLineNumber(),

src/main/java/com/hubspot/jinjava/lib/tag/TagLibrary.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,9 @@ protected void registerDefaults() {
2929
registerClasses(
3030
AutoEscapeTag.class,
3131
BlockTag.class,
32+
BreakTag.class,
3233
CallTag.class,
34+
ContinueTag.class,
3335
CycleTag.class,
3436
ElseTag.class,
3537
ElseIfTag.class,

src/main/java/com/hubspot/jinjava/lib/tag/eager/EagerForTag.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -179,8 +179,8 @@ private EagerExecutionResult runLoopOnce(
179179
) {
180180
return EagerContextWatcher.executeInChildContext(
181181
eagerInterpreter -> {
182-
if (!(eagerInterpreter.getContext().get("loop") instanceof DeferredValue)) {
183-
eagerInterpreter.getContext().put("loop", DeferredValue.instance());
182+
if (!(eagerInterpreter.getContext().get(ForTag.LOOP) instanceof DeferredValue)) {
183+
eagerInterpreter.getContext().put(ForTag.LOOP, DeferredValue.instance());
184184
}
185185
List<String> loopVars = getTag()
186186
.getLoopVarsAndExpression((TagToken) tagNode.getMaster())

src/main/java/com/hubspot/jinjava/util/EagerContextWatcher.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import com.hubspot.jinjava.interpret.MetaContextVariables;
1212
import com.hubspot.jinjava.interpret.OneTimeReconstructible;
1313
import com.hubspot.jinjava.interpret.RevertibleObject;
14+
import com.hubspot.jinjava.lib.tag.ForTag;
1415
import com.hubspot.jinjava.lib.tag.eager.EagerExecutionResult;
1516
import com.hubspot.jinjava.objects.collections.PyList;
1617
import com.hubspot.jinjava.objects.collections.PyMap;
@@ -211,7 +212,7 @@ private static Map<String, Object> getBasicSpeculativeBindings(
211212
)
212213
)
213214
.filter(entry -> !ignoredKeys.contains(entry.getKey()))
214-
.filter(entry -> !"loop".equals(entry.getKey()))
215+
.filter(entry -> !ForTag.LOOP.equals(entry.getKey()))
215216
.map(entry -> {
216217
if (
217218
eagerExecutionResult.getResult().isFullyResolved() ||

src/main/java/com/hubspot/jinjava/util/ForLoop.java

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ public class ForLoop implements Iterator<Object> {
2828
private int length = NULL_VALUE;
2929
private boolean first = true;
3030
private boolean last;
31+
private boolean continued;
32+
private boolean broken;
3133

3234
private int depth;
3335

@@ -45,6 +47,8 @@ public ForLoop(Iterator<?> ite, int len) {
4547
last = false;
4648
}
4749
it = ite;
50+
continued = false;
51+
broken = false;
4852
}
4953

5054
public ForLoop(Iterator<?> ite) {
@@ -57,10 +61,16 @@ public ForLoop(Iterator<?> ite) {
5761
revcounter = 2;
5862
last = true;
5963
}
64+
continued = false;
65+
broken = false;
6066
}
6167

6268
@Override
6369
public Object next() {
70+
if (broken) {
71+
return null;
72+
}
73+
continued = false;
6474
Object res;
6575
if (it.hasNext()) {
6676
index++;
@@ -129,8 +139,24 @@ public boolean isLast() {
129139
return last;
130140
}
131141

142+
public boolean isContinued() {
143+
return continued;
144+
}
145+
146+
public void doContinue() {
147+
continued = true;
148+
}
149+
150+
public void doBreak() {
151+
continued = true;
152+
broken = true;
153+
}
154+
132155
@Override
133156
public boolean hasNext() {
157+
if (broken) {
158+
return false;
159+
}
134160
return it.hasNext();
135161
}
136162

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
package com.hubspot.jinjava.lib.tag;
2+
3+
import static org.assertj.core.api.Assertions.assertThat;
4+
5+
import com.hubspot.jinjava.BaseInterpretingTest;
6+
import com.hubspot.jinjava.interpret.RenderResult;
7+
import com.hubspot.jinjava.interpret.TemplateError;
8+
import org.junit.Test;
9+
10+
public class BreakTagTest extends BaseInterpretingTest {
11+
12+
@Test
13+
public void testBreak() {
14+
String template =
15+
"{% for item in [1, 2, 3, 4] %}{% if item > 2 %}{% break %}{% endif %}{{ item }}{% endfor %}";
16+
17+
RenderResult rendered = jinjava.renderForResult(template, context);
18+
assertThat(rendered.getOutput()).isEqualTo("12");
19+
}
20+
21+
@Test
22+
public void testNestedBreak() {
23+
String template =
24+
"{% for item in [1, 2, 3, 4] %}{% for item2 in [5, 6, 7] %}{% break %}{{ item2 }}{% endfor %}{{ item }}{% endfor %}";
25+
26+
RenderResult rendered = jinjava.renderForResult(template, context);
27+
assertThat(rendered.getOutput()).isEqualTo("1234");
28+
}
29+
30+
@Test
31+
public void testBreakWithEarlierContent() {
32+
String template =
33+
"{% for item in [1, 2, 3, 4] %}{{ item }}{% if item > 2 %}{% break %}{% endif %}{{ item }}{% endfor %}";
34+
35+
RenderResult rendered = jinjava.renderForResult(template, context);
36+
assertThat(rendered.getOutput()).isEqualTo("11223");
37+
}
38+
39+
@Test
40+
public void testBreakOutOfContext() {
41+
String template = "{% break %}";
42+
43+
RenderResult rendered = jinjava.renderForResult(template, context);
44+
assertThat(rendered.getOutput()).isEqualTo("");
45+
assertThat(rendered.getErrors()).hasSize(1);
46+
assertThat(rendered.getErrors().get(0).getSeverity())
47+
.isEqualTo(TemplateError.ErrorType.FATAL);
48+
assertThat(rendered.getErrors().get(0).getMessage())
49+
.contains("NotInLoopException: `break` called while not in a for loop");
50+
}
51+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
package com.hubspot.jinjava.lib.tag;
2+
3+
import static org.assertj.core.api.Assertions.assertThat;
4+
5+
import com.hubspot.jinjava.BaseInterpretingTest;
6+
import com.hubspot.jinjava.interpret.RenderResult;
7+
import com.hubspot.jinjava.interpret.TemplateError;
8+
import org.junit.Test;
9+
10+
public class ContinueTagTest extends BaseInterpretingTest {
11+
12+
@Test
13+
public void testContinue() {
14+
String template =
15+
"{% for item in [1, 2, 3, 4] %}{% if item % 2 == 0 %}{% continue %}{% endif %}{{ item }}{% endfor %}";
16+
17+
RenderResult rendered = jinjava.renderForResult(template, context);
18+
assertThat(rendered.getOutput()).isEqualTo("13");
19+
}
20+
21+
@Test
22+
public void testNestedContinue() {
23+
String template =
24+
"{% for item in [1, 2, 3, 4] %}{% for item2 in [5, 6, 7] %}{% continue %}{{ item2 }}{% endfor %}{{ item }}{% endfor %}";
25+
26+
RenderResult rendered = jinjava.renderForResult(template, context);
27+
assertThat(rendered.getOutput()).isEqualTo("1234");
28+
}
29+
30+
@Test
31+
public void testContinueWithEarlierContent() {
32+
String template =
33+
"{% for item in [1, 2, 3, 4] %}{{ item }}{% if item % 2 == 0 %}{% continue %}{% endif %}{{ item }}{% endfor %}";
34+
35+
RenderResult rendered = jinjava.renderForResult(template, context);
36+
assertThat(rendered.getOutput()).isEqualTo("112334");
37+
}
38+
39+
@Test
40+
public void testContinueOutOfContext() {
41+
String template = "{% continue %}";
42+
43+
RenderResult rendered = jinjava.renderForResult(template, context);
44+
assertThat(rendered.getOutput()).isEqualTo("");
45+
assertThat(rendered.getErrors()).hasSize(1);
46+
assertThat(rendered.getErrors().get(0).getSeverity())
47+
.isEqualTo(TemplateError.ErrorType.FATAL);
48+
assertThat(rendered.getErrors().get(0).getMessage())
49+
.contains("NotInLoopException: `continue` called while not in a for loop");
50+
}
51+
}

0 commit comments

Comments
 (0)