From 4b0ee18fabca04cfc9528136ebddc01590bc7d05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Kijewski?= Date: Wed, 28 Jan 2026 18:43:10 +0100 Subject: [PATCH] Implement compound assignments (e.g. `{% mut a += 1 %}`) --- askama_derive/src/generator.rs | 6 +- askama_derive/src/tests.rs | 37 ++++++ askama_parser/src/expr.rs | 22 ++-- askama_parser/src/lib.rs | 2 +- askama_parser/src/node.rs | 61 ++++++++-- book/src/template_syntax.md | 24 ++++ testing/tests/compound-assignment.rs | 169 +++++++++++++++++++++++++++ 7 files changed, 296 insertions(+), 25 deletions(-) create mode 100644 testing/tests/compound-assignment.rs diff --git a/askama_derive/src/generator.rs b/askama_derive/src/generator.rs index 6bdae09f..71722256 100644 --- a/askama_derive/src/generator.rs +++ b/askama_derive/src/generator.rs @@ -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 => + * / % + - << >> & ^ | == != < > <= >= && || .. ..= + = += -= *= /= %= &= |= ^= <<= >>= + ) } diff --git a/askama_derive/src/tests.rs b/askama_derive/src/tests.rs index b853fe20..95d8712a 100644 --- a/askama_derive/src/tests.rs +++ b/askama_derive/src/tests.rs @@ -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); + } +} diff --git a/askama_parser/src/expr.rs b/askama_parser/src/expr.rs index d1bfb579..cb787128 100644 --- a/askama_parser/src/expr.rs +++ b/askama_parser/src/expr.rs @@ -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("&") } } diff --git a/askama_parser/src/lib.rs b/askama_parser/src/lib.rs index 867849f9..894b7c7c 100644 --- a/askama_parser/src/lib.rs +++ b/askama_parser/src/lib.rs @@ -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. diff --git a/askama_parser/src/node.rs b/askama_parser/src/node.rs index 2cdb8355..e0822547 100644 --- a/askama_parser/src/node.rs +++ b/askama_parser/src/node.rs @@ -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>> { + 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(( + // + "=", "+=", "-=", "*=", "/=", "%=", "&=", "|=", "^=", "<<=", ">>=", + ))), + 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)] diff --git a/book/src/template_syntax.md b/book/src/template_syntax.md index 04876afd..efd86623 100644 --- a/book/src/template_syntax.md +++ b/book/src/template_syntax.md @@ -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]: "Rust - Quick start: Assignment and Compound Assignment Operators" +[wikipedia-prefix-sum]: "Wikipedia: Prefix sum" +[reference-compound-assignment]: "The Rust Reference: Compound assignment expressions" + ## Filters Values such as those obtained from variables can be post-processed diff --git a/testing/tests/compound-assignment.rs b/testing/tests/compound-assignment.rs new file mode 100644 index 00000000..eb8b5805 --- /dev/null +++ b/testing/tests/compound-assignment.rs @@ -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"); +}