Implement compound assignments (e.g. {% mut a += 1 %})

This commit is contained in:
René Kijewski 2026-01-28 18:43:10 +01:00
parent 92fd0be6e3
commit 4b0ee18fab
7 changed files with 296 additions and 25 deletions

View File

@ -849,5 +849,9 @@ fn range_op(op: &str, span: proc_macro2::Span) -> TokenStream {
#[inline]
#[track_caller]
fn binary_op(op: &str, span: proc_macro2::Span) -> TokenStream {
make_token_match!(op @ span => * / % + - << >> & ^ | == != < > <= >= && || .. ..=)
make_token_match!(
op @ span =>
* / % + - << >> & ^ | == != < > <= >= && || .. ..=
= += -= *= /= %= &= |= ^= <<= >>=
)
}

View File

@ -1539,3 +1539,40 @@ fn regression_tests_span_change() {
struct Foo;
});
}
#[test]
fn test_compound_assignment() {
for op in [
"=", "+=", "-=", "*=", "/=", "%=", "&=", "|=", "^=", "<<=", ">>=",
] {
let jinja = r#"
{%- let mut prefixsum = 0 -%}
{%- for i in 0..limit -%}
{%- mut prefixsum @= i -%}
{{ prefixsum }}.
{%- endfor -%}
"#
.replace("@=", op);
let expected = r#"
let mut prefixsum = 0;
let __askama_iter = 0..self.limit;
for (i, __askama_item) in askama::helpers::TemplateLoop::new(__askama_iter) {
let _ = prefixsum @= i;
match (
&((&&askama::filters::AutoEscaper::new(&(prefixsum), askama::filters::Text))
.askama_auto_escape()?),
) {
(__askama_expr0,) => {
(&&&askama::filters::Writable(__askama_expr0))
.askama_write(__askama_writer, __askama_values)?;
}
}
__askama_writer.write_str(".")?;
}
"#
.replace("@=", op);
compare(&jinja, &expected, &[("limit", "u32")], 6);
}
}

View File

@ -790,24 +790,22 @@ impl<'a: 'l, 'l> Expr<'a> {
}
fn token_xor<'a: 'l, 'l>(i: &mut InputStream<'a, 'l>) -> ParseResult<'a> {
let (good, span) = alt((keyword("xor").value(true), '^'.value(false)))
.with_span()
.parse_next(i)?;
if good {
Ok("^")
} else {
let good = keyword("xor").value(None);
let bad = ('^', not('=')).span().map(Some);
if let Some(span) = alt((good, bad)).parse_next(i)? {
cut_error!("the binary XOR operator is called `xor` in askama", span)
} else {
Ok("^")
}
}
fn token_bitand<'a: 'l, 'l>(i: &mut InputStream<'a, 'l>) -> ParseResult<'a> {
let (good, span) = alt((keyword("bitand").value(true), ('&', not('&')).value(false)))
.with_span()
.parse_next(i)?;
if good {
Ok("&")
} else {
let good = keyword("bitand").value(None);
let bad = ('&', not(one_of(['&', '=']))).span().map(Some);
if let Some(span) = alt((good, bad)).parse_next(i)? {
cut_error!("the binary AND operator is called `bitand` in askama", span)
} else {
Ok("&")
}
}

View File

@ -1358,7 +1358,7 @@ impl LevelGuard<'_> {
}
fn filter<'a: 'l, 'l>(i: &mut InputStream<'a, 'l>) -> ParseResult<'a, Filter<'a>> {
preceded(('|', not('|')), cut_err(Filter::parse)).parse_next(i)
preceded(('|', not(one_of(['|', '=']))), cut_err(Filter::parse)).parse_next(i)
}
/// Returns the common parts of two paths.

View File

@ -9,6 +9,7 @@ use winnow::stream::{Location, Stream};
use winnow::token::{any, literal, rest, take, take_until};
use winnow::{ModalParser, Parser};
use crate::expr::BinOp;
use crate::{
ErrorContext, Expr, Filter, HashSet, InputStream, ParseErr, ParseResult, Span, Target,
WithSpan, block_end, block_start, cut_error, expr_end, expr_start, filter, identifier,
@ -93,21 +94,22 @@ impl<'a: 'l, 'l> Node<'a> {
.parse_next(i)?;
let func = match tag {
"call" => Call::parse,
"decl" | "declare" => Declare::parse,
"let" | "set" => Let::parse,
"if" => If::parse,
"for" => Loop::parse,
"match" => Match::parse,
"extends" => Extends::parse,
"include" => Include::parse,
"import" => Import::parse,
"block" => BlockDef::parse,
"macro" => Macro::parse,
"raw" => Raw::parse,
"break" => Self::r#break,
"call" => Call::parse,
"continue" => Self::r#continue,
"decl" | "declare" => Declare::parse,
"extends" => Extends::parse,
"filter" => FilterBlock::parse,
"for" => Loop::parse,
"if" => If::parse,
"import" => Import::parse,
"include" => Include::parse,
"let" | "set" => Let::parse,
"macro" => Macro::parse,
"match" => Match::parse,
"mut" => Let::compound,
"raw" => Raw::parse,
_ => {
i.reset(&start);
return fail.parse_next(i);
@ -1299,6 +1301,43 @@ impl<'a: 'l, 'l> Let<'a> {
span,
))))
}
fn compound(i: &mut InputStream<'a, 'l>) -> ParseResult<'a, Box<Node<'a>>> {
let (pws, span, (lhs, op, rhs, nws)) = (
opt(Whitespace::parse),
ws(keyword("mut").span()),
cut_node(
Some("mut"),
(
|i: &mut _| Expr::parse(i, false),
ws(alt((
// <https://doc.rust-lang.org/reference/expressions/operator-expr.html#compound-assignment-expressions>
"=", "+=", "-=", "*=", "/=", "%=", "&=", "|=", "^=", "<<=", ">>=",
))),
ws(|i: &mut _| Expr::parse(i, false)),
opt(Whitespace::parse),
),
),
)
.parse_next(i)?;
// For `a += b` this AST generates the code `let _ = a += b;`. This may look odd, but
// is valid rust code, because the value of any assignment (compound or not) is `()`.
// This way the generator does not need to know about compound assignments for them
// to work.
Ok(Box::new(Node::Let(WithSpan::new(
Let {
ws: Ws(pws, nws),
var: Target::Placeholder(WithSpan::new((), span.clone())),
val: Some(WithSpan::new(
Box::new(Expr::BinOp(BinOp { op, lhs, rhs })),
span.clone(),
)),
is_mutable: false,
},
span,
))))
}
}
#[derive(Debug, PartialEq)]

View File

@ -117,6 +117,30 @@ to prevent changing ownership. The rules are as follows:
* If the value is a field (`x.y`), it WILL BE put behind a reference.
* If the expression ends with a question mark (like `x?`), it WILL NOT BE put behind a reference.
### Compound assignments
Using the keyword `mut`, [compound assignments][rust101-assignment-op] (also called
"augmented assignments"), such as `x += 1` to increment `x` by 1, are possible, too:
```jinja
{% let mut counter = 0 %}
{% for i in 1..=10 %}
{% mut counter += 1 %}
{{ counter }}
{% endfor %}
```
This example will output [`1 3 6 10 15`…][wikipedia-prefix-sum].
The target can be a variable or a more complex expression. The rules are the same as in rust,
e.g. the left-hand side of the expression, i.e. the assignment target, must be mutable.
All [compound assignment operators][reference-compound-assignment] that are valid in rust are
valid in askama, too.
[rust101-assignment-op]: <https://rustlabs.github.io/docs/rust101/assignment_compound_assignment_operators/#compound-assignment-operator> "Rust - Quick start: Assignment and Compound Assignment Operators"
[wikipedia-prefix-sum]: <https://en.wikipedia.org/wiki/Prefix_sum?curid=6109308> "Wikipedia: Prefix sum"
[reference-compound-assignment]: <https://doc.rust-lang.org/reference/expressions/operator-expr.html#compound-assignment-expressions> "The Rust Reference: Compound assignment expressions"
## Filters
Values such as those obtained from variables can be post-processed

View File

@ -0,0 +1,169 @@
use std::cell::Cell;
use askama::Template;
#[test]
fn test_prefixsum() {
#[derive(Template)]
#[template(
ext = "txt",
source = "
{%- let mut prefixsum = 0 -%}
{%- for i in 0..limit -%}
{%- mut prefixsum += i -%}
{{ prefixsum }}.
{%- endfor -%}
"
)]
struct PrefixSum {
limit: u32,
}
assert_eq!(
PrefixSum { limit: 10 }.render().unwrap(),
"0.1.3.6.10.15.21.28.36.45."
);
}
#[test]
fn test_expr_on_lhs() {
#[derive(Template)]
#[template(
ext = "txt",
source = "
{%- let mut prefixsum = Cell::new(0u32) -%}
{%- for i in 0..limit -%}
{%- mut *prefixsum.get_mut() += i -%}
{{ prefixsum.get() }}.
{%- endfor -%}
"
)]
struct PrefixSum {
limit: u32,
}
assert_eq!(
PrefixSum { limit: 10 }.render().unwrap(),
"0.1.3.6.10.15.21.28.36.45."
);
}
#[test]
fn test_add() {
#[derive(Template)]
#[template(
ext = "txt",
source = "{% let mut value = 9 %} {%- mut value += 2 -%} {{ value }}"
)]
struct Test;
assert_eq!(Test.render().unwrap(), "11");
}
#[test]
fn test_sub() {
#[derive(Template)]
#[template(
ext = "txt",
source = "{% let mut value = 9 %} {%- mut value -= 2 -%} {{ value }}"
)]
struct Test;
assert_eq!(Test.render().unwrap(), "7");
}
#[test]
fn test_mul() {
#[derive(Template)]
#[template(
ext = "txt",
source = "{% let mut value = 9 %} {%- mut value *= 2 -%} {{ value }}"
)]
struct Test;
assert_eq!(Test.render().unwrap(), "18");
}
#[test]
fn test_div() {
#[derive(Template)]
#[template(
ext = "txt",
source = "{% let mut value = 9 %} {%- mut value /= 2 -%} {{ value }}"
)]
struct Test;
assert_eq!(Test.render().unwrap(), "4");
}
#[test]
fn test_rem() {
#[derive(Template)]
#[template(
ext = "txt",
source = "{% let mut value = 9 %} {%- mut value %= 2 -%} {{ value }}"
)]
struct Test;
assert_eq!(Test.render().unwrap(), "1");
}
#[test]
fn test_and() {
#[derive(Template)]
#[template(
ext = "txt",
source = "{% let mut value = 9 %} {%- mut value &= 3 -%} {{ value }}"
)]
struct Test;
assert_eq!(Test.render().unwrap(), "1");
}
#[test]
fn test_or() {
#[derive(Template)]
#[template(
ext = "txt",
source = "{% let mut value = 9 %} {%- mut value |= 3 -%} {{ value }}"
)]
struct Test;
assert_eq!(Test.render().unwrap(), "11");
}
#[test]
fn test_xor() {
#[derive(Template)]
#[template(
ext = "txt",
source = "{% let mut value = 9 %} {%- mut value ^= 3 -%} {{ value }}"
)]
struct Test;
assert_eq!(Test.render().unwrap(), "10");
}
#[test]
fn test_shl() {
#[derive(Template)]
#[template(
ext = "txt",
source = "{% let mut value = 9 %} {%- mut value <<= 2 -%} {{ value }}"
)]
struct Test;
assert_eq!(Test.render().unwrap(), "36");
}
#[test]
fn test_shr() {
#[derive(Template)]
#[template(
ext = "txt",
source = "{% let mut value = 9 %} {%- mut value >>= 2 -%} {{ value }}"
)]
struct Test;
assert_eq!(Test.render().unwrap(), "2");
}