Skip to content

Commit

Permalink
Add support for break and continue (#558)
Browse files Browse the repository at this point in the history
  • Loading branch information
mitsuhiko authored Aug 14, 2024
1 parent f8b5562 commit dff9f46
Show file tree
Hide file tree
Showing 16 changed files with 183 additions and 21 deletions.
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@

All notable changes to MiniJinja are documented here.

## 2.1.3
## 2.2.0

- Fixes a bug where some enums did not deserialize correctly when
used with `ViaDeserialize`. #554
- Implemented `IntoDeserializer` for `Value` and `&Value`. #555
- Added `filesizeformat` to minijinja-contrib. #556
- Added support for the `loop_controls` feature which adds
`{% break %}` and `{% continue %}`. #558

## 2.1.2

Expand Down
4 changes: 2 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
DOC_FEATURES=loader,json,urlencode,custom_syntax,fuel
TEST_FEATURES=unstable_machinery,builtins,loader,json,urlencode,debug,internal_debug,macros,multi_template,adjacent_loop_items,custom_syntax,deserialization,serde
TEST_FEATURES=unstable_machinery,builtins,loader,json,urlencode,debug,internal_debug,macros,multi_template,adjacent_loop_items,custom_syntax,deserialization,serde,loop_controls

.PHONY: all
all: test
Expand Down Expand Up @@ -40,7 +40,7 @@ snapshot-tests:
run-tests:
@rustup component add rustfmt 2> /dev/null
@echo "CARGO TESTS"
@cd minijinja; cargo test --features=json,urlencode,internal_debug
@cd minijinja; cargo test --features=json,urlencode,internal_debug,loop_controls
@echo "CARGO TEST SPEEDUPS"
@cd minijinja; cargo test --no-default-features --features=speedups,$(TEST_FEATURES)
@echo "CARGO CHECK NO_DEFAULT_FEATURES"
Expand Down
1 change: 1 addition & 0 deletions minijinja-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ minijinja = { version = "=2.1.2", path = "../minijinja", features = [
"fuel",
"unstable_machinery",
"custom_syntax",
"loop_controls"
] }
minijinja-contrib = { version = "=2.1.2", optional = true, path = "../minijinja-contrib", features = ["pycompat", "datetime", "timezone", "rand"] }
rustyline = { version = "12.0.0", optional = true }
Expand Down
1 change: 1 addition & 0 deletions minijinja/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ builtins = []
macros = []
multi_template = []
adjacent_loop_items = []
loop_controls = []
fuel = []

# Extra Filters
Expand Down
20 changes: 20 additions & 0 deletions minijinja/src/compiler/ast.rs
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,10 @@ pub enum Stmt<'a> {
Macro(Spanned<Macro<'a>>),
#[cfg(feature = "macros")]
CallBlock(Spanned<CallBlock<'a>>),
#[cfg(feature = "loop_controls")]
Continue(Spanned<Continue>),
#[cfg(feature = "loop_controls")]
Break(Spanned<Break>),
Do(Spanned<Do<'a>>),
}

Expand Down Expand Up @@ -111,6 +115,10 @@ impl<'a> fmt::Debug for Stmt<'a> {
Stmt::Macro(s) => fmt::Debug::fmt(s, f),
#[cfg(feature = "macros")]
Stmt::CallBlock(s) => fmt::Debug::fmt(s, f),
#[cfg(feature = "loop_controls")]
Stmt::Continue(s) => fmt::Debug::fmt(s, f),
#[cfg(feature = "loop_controls")]
Stmt::Break(s) => fmt::Debug::fmt(s, f),
Stmt::Do(s) => fmt::Debug::fmt(s, f),
}
}
Expand Down Expand Up @@ -298,6 +306,18 @@ pub struct CallBlock<'a> {
pub macro_decl: Spanned<Macro<'a>>,
}

/// Continue
#[cfg_attr(feature = "internal_debug", derive(Debug))]
#[cfg(feature = "loop_controls")]
#[cfg_attr(feature = "unstable_machinery_serde", derive(serde::Serialize))]
pub struct Continue;

/// Break
#[cfg_attr(feature = "internal_debug", derive(Debug))]
#[cfg(feature = "loop_controls")]
#[cfg_attr(feature = "unstable_machinery_serde", derive(serde::Serialize))]
pub struct Break;

/// A call block
#[cfg_attr(feature = "internal_debug", derive(Debug))]
#[cfg_attr(feature = "unstable_machinery_serde", derive(serde::Serialize))]
Expand Down
58 changes: 40 additions & 18 deletions minijinja/src/compiler/codegen.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ fn get_local_id<'source>(ids: &mut BTreeMap<&'source str, LocalId>, name: &'sour
/// jump targets.
enum PendingBlock {
Branch(usize),
Loop(usize),
Loop(usize, Vec<usize>),
ScBool(Vec<usize>),
}

Expand Down Expand Up @@ -135,29 +135,30 @@ impl<'source> CodeGenerator<'source> {
flags |= LOOP_FLAG_RECURSIVE;
}
self.add(Instruction::PushLoop(flags));
let iter_instr = self.add(Instruction::Iterate(!0));
self.pending_block.push(PendingBlock::Loop(iter_instr));
let instr = self.add(Instruction::Iterate(!0));
self.pending_block.push(PendingBlock::Loop(instr, vec![]));
}

/// Ends the open for loop
pub fn end_for_loop(&mut self, push_did_not_iterate: bool) {
match self.pending_block.pop() {
Some(PendingBlock::Loop(iter_instr)) => {
self.add(Instruction::Jump(iter_instr));
let loop_end = self.next_instruction();
if push_did_not_iterate {
self.add(Instruction::PushDidNotIterate);
};
self.add(Instruction::PopFrame);
if let Some(Instruction::Iterate(ref mut jump_target)) =
self.instructions.get_mut(iter_instr)
{
*jump_target = loop_end;
} else {
unreachable!();
if let Some(PendingBlock::Loop(iter_instr, breaks)) = self.pending_block.pop() {
self.add(Instruction::Jump(iter_instr));
let loop_end = self.next_instruction();
if push_did_not_iterate {
self.add(Instruction::PushDidNotIterate);
};
self.add(Instruction::PopFrame);
for instr in breaks.into_iter().chain(Some(iter_instr)) {
match self.instructions.get_mut(instr) {
Some(Instruction::Iterate(ref mut jump_target))
| Some(Instruction::Jump(ref mut jump_target)) => {
*jump_target = loop_end;
}
_ => unreachable!(),
}
}
_ => unreachable!(),
} else {
unreachable!()
}
}

Expand Down Expand Up @@ -347,6 +348,27 @@ impl<'source> CodeGenerator<'source> {
ast::Stmt::CallBlock(call_block) => {
self.compile_call_block(call_block);
}
#[cfg(feature = "loop_controls")]
ast::Stmt::Continue(cont) => {
self.set_line_from_span(cont.span());
for pending_block in self.pending_block.iter().rev() {
if let PendingBlock::Loop(instr, _) = pending_block {
self.add(Instruction::Jump(*instr));
break;
}
}
}
#[cfg(feature = "loop_controls")]
ast::Stmt::Break(brk) => {
self.set_line_from_span(brk.span());
let instr = self.add(Instruction::Jump(0));
for pending_block in self.pending_block.iter_mut().rev() {
if let PendingBlock::Loop(_, ref mut breaks) = pending_block {
breaks.push(instr);
break;
}
}
}
ast::Stmt::Do(do_tag) => {
self.compile_do(do_tag);
}
Expand Down
2 changes: 2 additions & 0 deletions minijinja/src/compiler/meta.rs
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,8 @@ fn track_walk<'a>(node: &ast::Stmt<'a>, state: &mut AssignmentTracker<'a>) {
.for_each(|x| tracker_visit_expr(x, state));
tracker_visit_macro(&stmt.macro_decl, state);
}
#[cfg(feature = "loop_controls")]
ast::Stmt::Continue(_) | ast::Stmt::Break(_) => {}
ast::Stmt::Do(stmt) => {
tracker_visit_expr(&stmt.call.expr, state);
stmt.call
Expand Down
23 changes: 23 additions & 0 deletions minijinja/src/compiler/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,8 @@ struct Parser<'a> {
#[allow(unused)]
in_macro: bool,
#[allow(unused)]
in_loop: bool,
#[allow(unused)]
blocks: BTreeSet<&'a str>,
depth: usize,
}
Expand Down Expand Up @@ -236,6 +238,7 @@ impl<'a> Parser<'a> {
Parser {
stream: TokenStream::new(source, in_expr, syntax_config, whitespace_config),
in_macro: false,
in_loop: false,
blocks: BTreeSet::new(),
depth: 0,
}
Expand Down Expand Up @@ -692,6 +695,20 @@ impl<'a> Parser<'a> {
"macro" => ast::Stmt::Macro(respan!(ok!(self.parse_macro()))),
#[cfg(feature = "macros")]
"call" => ast::Stmt::CallBlock(respan!(ok!(self.parse_call_block()))),
#[cfg(feature = "loop_controls")]
"continue" => {
if !self.in_loop {
syntax_error!("'continue' must be placed inside a loop");
}
ast::Stmt::Continue(respan!(ast::Continue))
}
#[cfg(feature = "loop_controls")]
"break" => {
if !self.in_loop {
syntax_error!("'break' must be placed inside a loop");
}
ast::Stmt::Break(respan!(ast::Break))
}
"do" => ast::Stmt::Do(respan!(ok!(self.parse_do()))),
name => syntax_error!("unknown statement {}", name),
})
Expand Down Expand Up @@ -758,6 +775,7 @@ impl<'a> Parser<'a> {
}

fn parse_for_stmt(&mut self) -> Result<ast::ForLoop<'a>, Error> {
let old_in_loop = std::mem::replace(&mut self.in_loop, true);
let target = ok!(self.parse_assignment());
expect_token!(self, Token::Ident("in"), "in");
let iter = ok!(self.parse_expr_noif());
Expand All @@ -776,6 +794,7 @@ impl<'a> Parser<'a> {
Vec::new()
};
ok!(self.stream.next());
self.in_loop = old_in_loop;
Ok(ast::ForLoop {
target,
iter,
Expand Down Expand Up @@ -872,6 +891,7 @@ impl<'a> Parser<'a> {
if self.in_macro {
syntax_error!("block tags in macros are not allowed");
}
let old_in_loop = std::mem::replace(&mut self.in_loop, false);
let (name, _) = expect_token!(self, Token::Ident(name) => name, "identifier");
if !self.blocks.insert(name) {
syntax_error!("block '{}' defined twice", name);
Expand All @@ -891,6 +911,7 @@ impl<'a> Parser<'a> {
}
ok!(self.stream.next());
}
self.in_loop = old_in_loop;

Ok(ast::Block { name, body })
}
Expand Down Expand Up @@ -1035,13 +1056,15 @@ impl<'a> Parser<'a> {
name: Option<&'a str>,
) -> Result<ast::Macro<'a>, Error> {
expect_token!(self, Token::BlockEnd, "end of block");
let old_in_loop = std::mem::replace(&mut self.in_loop, false);
let old_in_macro = std::mem::replace(&mut self.in_macro, true);
let body = ok!(self.subparse(&|tok| match tok {
Token::Ident("endmacro") if name.is_some() => true,
Token::Ident("endcall") if name.is_none() => true,
_ => false,
}));
self.in_macro = old_in_macro;
self.in_loop = old_in_loop;
ok!(self.stream.next());
Ok(ast::Macro {
name: name.unwrap_or("caller"),
Expand Down
2 changes: 2 additions & 0 deletions minijinja/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,8 @@
//! - `json`: When enabled the `tojson` filter is added as builtin filter as well as
//! the ability to auto escape via `AutoEscape::Json`.
//! - `urlencode`: When enabled the `urlencode` filter is added as builtin filter.
//! - `loop_controls`: enables the `{% break %}` and `{% continue %}` loop control flow
//! tags.
//!
//! Performance and memory related features:
//!
Expand Down
25 changes: 25 additions & 0 deletions minijinja/src/syntax.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
//! - [`{% do %}`](#-do-)
//! - [`{% autoescape %}`](#-autoescape-)
//! - [`{% raw %}`](#-raw-)
//! - [`{% break %} / {% continue %}`](#-break----continue-)
#![cfg_attr(
feature = "custom_syntax",
doc = "- [Custom Delimiters](#custom-delimiters)"
Expand Down Expand Up @@ -721,6 +722,30 @@
//! {% endraw %}
//! ```
//!
//! ## `{% break %}` / `{% continue %}`
//!
//! If MiniJinja was compiled with the `loop_controls` feature, it’s possible to
//! use `break`` and `continue`` in loops. When break is reached, the loop is
//! terminated; if continue is reached, the processing is stopped and continues
//! with the next iteration.
//!
//! Here’s a loop that skips every second item:
//!
//! ```jinja
//! {% for user in users %}
//! {%- if loop.index is even %}{% continue %}{% endif %}
//! ...
//! {% endfor %}
//! ```
//!
//! Likewise, a loop that stops processing after the 10th iteration:
//!
//! ```jinja
//! {% for user in users %}
//! {%- if loop.index >= 10 %}{% break %}{% endif %}
//! {%- endfor %}
//! ```
//!
#![cfg_attr(
feature = "custom_syntax",
doc = r###"
Expand Down
3 changes: 3 additions & 0 deletions minijinja/tests/inputs/err_toplevel_break.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{}
---
{% break %}
3 changes: 3 additions & 0 deletions minijinja/tests/inputs/err_toplevel_continue.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{}
---
{% continue %}
7 changes: 7 additions & 0 deletions minijinja/tests/inputs/loop_controls.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{}
---
{% for item in range(10) -%}
{%- if item is odd %}{% continue %}{% endif %}
{%- if item > 5 %}{% break %}{% endif %}
{{- item }}
{%- endfor %}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
source: minijinja/tests/test_templates.rs
description: "{% break %}"
info: {}
input_file: minijinja/tests/inputs/err_toplevel_break.txt
---
!!!SYNTAX ERROR!!!

Error {
kind: SyntaxError,
detail: "'break' must be placed inside a loop",
name: "err_toplevel_break.txt",
line: 1,
}

syntax error: 'break' must be placed inside a loop (in err_toplevel_break.txt:1)
--------------------------- err_toplevel_break.txt ----------------------------
1 > {% break %}
i ^^^^^ syntax error
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
No referenced variables
-------------------------------------------------------------------------------
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
source: minijinja/tests/test_templates.rs
description: "{% continue %}"
info: {}
input_file: minijinja/tests/inputs/err_toplevel_continue.txt
---
!!!SYNTAX ERROR!!!

Error {
kind: SyntaxError,
detail: "'continue' must be placed inside a loop",
name: "err_toplevel_continue.txt",
line: 1,
}

syntax error: 'continue' must be placed inside a loop (in err_toplevel_continue.txt:1)
-------------------------- err_toplevel_continue.txt --------------------------
1 > {% continue %}
i ^^^^^^^^ syntax error
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
No referenced variables
-------------------------------------------------------------------------------
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
source: minijinja/tests/test_templates.rs
description: "{% for item in range(10) -%}\n {%- if item is odd %}{% continue %}{% endif %}\n {%- if item > 5 %}{% break %}{% endif %}\n {{- item }}\n{%- endfor %}"
info: {}
input_file: minijinja/tests/inputs/loop_controls.txt
---
024

0 comments on commit dff9f46

Please sign in to comment.