Merge pull request #697 from GuillaumeGomez/let-blocks

Add support for `let blocks`
This commit is contained in:
Guillaume Gomez 2026-02-17 11:47:29 +01:00 committed by GitHub
commit 313f2ca655
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 348 additions and 87 deletions

View File

@ -36,7 +36,7 @@ pub(crate) fn template_to_string(
heritage,
MapChain::default(),
input.block.is_some(),
0,
BlockInfo::new(),
);
let size_hint = match generator.impl_template(buf, tmpl_kind) {
Err(mut err) if err.span.is_none() => {
@ -72,6 +72,34 @@ enum RenderFor {
Extends,
}
#[derive(Clone, Copy)]
struct BlockInfo {
block_name: &'static str,
level: usize,
}
impl BlockInfo {
fn new() -> Self {
Self {
block_name: "",
level: 0,
}
}
// FIXME: Instead of this error-prone API, we should use something relying on `Drop` to
// decrement, or use a callback which would decrement on exit.
fn increase(&mut self, block_name: &'static str) {
if self.level == 0 {
self.block_name = block_name;
}
self.level += 1;
}
fn decrease(&mut self) {
self.level -= 1;
}
}
struct Generator<'a, 'h> {
/// The template input state: original struct AST and attributes
input: &'a TemplateInput<'a>,
@ -92,8 +120,8 @@ struct Generator<'a, 'h> {
super_block: Option<(&'a str, usize)>,
/// Buffer for writable
buf_writable: WritableBuffer<'a>,
/// Used in blocks to check if we are inside a filter block.
is_in_filter_block: usize,
/// Used in blocks to check if we are inside a filter/let block.
is_in_block: BlockInfo,
/// Set of called macros we are currently in. Used to prevent (indirect) recursions.
seen_callers: Vec<(&'a Macro<'a>, Option<FileInfo<'a>>)>,
/// The directory path of the calling file.
@ -113,7 +141,7 @@ impl<'a, 'h> Generator<'a, 'h> {
heritage: Option<&'h Heritage<'a, 'h>>,
locals: MapChain<'a>,
buf_writable_discard: bool,
is_in_filter_block: usize,
is_in_block: BlockInfo,
) -> Self {
Self {
input,
@ -127,7 +155,7 @@ impl<'a, 'h> Generator<'a, 'h> {
discard: buf_writable_discard,
..Default::default()
},
is_in_filter_block,
is_in_block,
seen_callers: Vec::new(),
caller_dir: CallerDir::Unresolved,
}

View File

@ -10,7 +10,7 @@ use parser::node::{
Call, Comment, Compound, Cond, CondTest, Declare, FilterBlock, If, Include, Let, Lit, Loop,
Match, Whitespace, Ws,
};
use parser::{Expr, Node, Span, Target, WithSpan};
use parser::{Expr, LetValueOrBlock, Node, Span, Target, WithSpan};
use proc_macro2::TokenStream;
use quote::quote_spanned;
use rustc_hash::FxBuildHasher;
@ -82,7 +82,7 @@ impl<'a> Generator<'a, '_> {
heritage,
locals,
self.buf_writable.discard,
self.is_in_filter_block,
self.is_in_block,
);
child.buf_writable = buf_writable;
let res = callback(&mut child);
@ -727,7 +727,7 @@ impl<'a> Generator<'a, '_> {
let mut size_hint = self.write_buf_writable(ctx, buf)?;
self.flush_ws(filter.ws1);
self.is_in_filter_block += 1;
self.is_in_block.increase("filter");
size_hint += self.write_buf_writable(ctx, buf)?;
let span = ctx.span_for_node(filter.span());
@ -785,7 +785,7 @@ impl<'a> Generator<'a, '_> {
}
} });
self.is_in_filter_block -= 1;
self.is_in_block.decrease();
self.prepare_ws(filter.ws2);
Ok(size_hint)
}
@ -926,47 +926,25 @@ impl<'a> Generator<'a, '_> {
fn write_let(
&mut self,
ctx: &Context<'_>,
ctx: &Context<'a>,
buf: &mut Buffer,
l: &'a WithSpan<Let<'_>>,
) -> Result<SizeHint, CompileError> {
self.handle_ws(l.ws);
let span = ctx.span_for_node(l.span());
let Some(val) = &l.val else {
let file_info = ctx
.file_info_of(l.span())
.map(|info| format!(" {info}:"))
.unwrap_or_default();
eprintln!(
"⚠️{file_info} `let` tag will stop supporting declaring variables without value. \
Use `create` instead for this case",
);
let size_hint = self.write_buf_writable(ctx, buf)?;
buf.write_token(Token![let], span);
if l.is_mutable {
buf.write_token(Token![mut], span);
}
self.visit_target(ctx, buf, false, true, &l.var, span);
buf.write_token(Token![;], span);
return Ok(size_hint);
};
// Handle when this statement creates a new alias of a caller variable (or of another alias),
if let Target::Name(dstvar) = l.var
&& let Expr::Var(srcvar) = ***val
&& let Some(caller_alias) = self.locals.get_caller(srcvar)
{
self.locals.insert(
Cow::Borrowed(*dstvar),
LocalMeta::CallerAlias(caller_alias.clone()),
);
return Ok(SizeHint::EMPTY);
match &l.val {
LetValueOrBlock::Value(val) => self.write_let_value(ctx, buf, l, val),
LetValueOrBlock::Block { nodes, ws } => self.write_let_block(ctx, buf, l, nodes, *ws),
}
}
let mut expr_buf = Buffer::new();
self.visit_expr(ctx, &mut expr_buf, val)?;
fn write_let_target(
&mut self,
ctx: &Context<'_>,
buf: &mut Buffer,
l: &'a WithSpan<Let<'_>>,
span: proc_macro2::Span,
) -> Result<SizeHint, CompileError> {
let shadowed = self.is_shadowing_variable(ctx, &l.var, l.span())?;
let size_hint = if shadowed {
// Need to flush the buffer if the variable is being shadowed,
@ -986,6 +964,104 @@ impl<'a> Generator<'a, '_> {
}
self.visit_target(ctx, buf, true, true, &l.var, span);
Ok(size_hint)
}
fn write_let_block(
&mut self,
ctx: &Context<'a>,
buf: &mut Buffer,
l: &'a WithSpan<Let<'_>>,
nodes: &'a [Box<Node<'a>>],
ws: Ws,
) -> Result<SizeHint, CompileError> {
let var_let_source = crate::var_let_source();
let mut size_hint = self.write_buf_writable(ctx, buf)?;
self.flush_ws(l.ws);
self.is_in_block.increase("let/set");
size_hint += self.write_buf_writable(ctx, buf)?;
let span = ctx.span_for_node(l.span());
// build `FmtCell` that contains the inner block
let mut filter_def_buf = Buffer::new();
size_hint += self.push_locals(|this| {
this.prepare_ws(l.ws);
let mut size_hint = this.handle(
ctx,
nodes,
&mut filter_def_buf,
AstLevel::Nested,
RenderFor::Template,
)?;
this.flush_ws(ws);
size_hint += this.write_buf_writable(ctx, &mut filter_def_buf)?;
Ok(size_hint)
})?;
let filter_def_buf = filter_def_buf.into_token_stream();
size_hint += self.write_let_target(ctx, buf, l, span)?;
buf.write_token(Token![=], span);
let var_writer = crate::var_writer();
let filter_def_buf = quote_spanned!(span=>
let #var_let_source = askama::helpers::FmtCell::new(
|#var_writer: &mut askama::helpers::core::fmt::Formatter<'_>| -> askama::Result<()> {
#filter_def_buf
askama::Result::Ok(())
}
);
);
// display the `FmtCell`
let mut filter_buf = Buffer::new();
quote_into!(&mut filter_buf, span, { askama::filters::Safe(&#var_let_source) });
let filter_buf = filter_buf.into_token_stream();
let escaper = TokenStream::from_str(self.input.escaper).unwrap();
let filter_buf = quote_spanned!(span=>
(&&askama::filters::AutoEscaper::new(
&(#filter_buf), #escaper
)).askama_auto_escape()?
);
quote_into!(buf, span, { {
#filter_def_buf
let mut __askama_tmp_write = String::new();
if askama::helpers::core::write!(&mut __askama_tmp_write, "{}", #filter_buf).is_err() {
return #var_let_source.take_err();
}
__askama_tmp_write
}; });
self.is_in_block.decrease();
self.prepare_ws(ws);
Ok(size_hint)
}
fn write_let_value(
&mut self,
ctx: &Context<'_>,
buf: &mut Buffer,
l: &'a WithSpan<Let<'_>>,
val: &WithSpan<Box<Expr<'a>>>,
) -> Result<SizeHint, CompileError> {
let span = ctx.span_for_node(l.span());
// Handle when this statement creates a new alias of a caller variable (or of another alias),
if let Target::Name(dstvar) = l.var
&& let Expr::Var(srcvar) = ***val
&& let Some(caller_alias) = self.locals.get_caller(srcvar)
{
self.locals.insert(
Cow::Borrowed(*dstvar),
LocalMeta::CallerAlias(caller_alias.clone()),
);
return Ok(SizeHint::EMPTY);
}
let mut expr_buf = Buffer::new();
self.visit_expr(ctx, &mut expr_buf, val)?;
let size_hint = self.write_let_target(ctx, buf, l, span)?;
// If it's not taking the ownership of a local variable or copyable, then we need to add
// a reference.
let borrow = !matches!(***val, Expr::Try(..))
@ -1036,8 +1112,14 @@ impl<'a> Generator<'a, '_> {
outer: Ws,
node: Span,
) -> Result<SizeHint, CompileError> {
if self.is_in_filter_block > 0 {
return Err(ctx.generate_error("cannot have a block inside a filter block", node));
if self.is_in_block.level > 0 {
return Err(ctx.generate_error(
format!(
"cannot have a block inside a {} block",
self.is_in_block.block_name
),
node,
));
}
// Flush preceding whitespace according to the outer WS spec
self.flush_ws(outer);

View File

@ -753,6 +753,10 @@ fn var_filter_source() -> Ident {
syn::Ident::new("__askama_filter_block", proc_macro2::Span::call_site())
}
fn var_let_source() -> Ident {
syn::Ident::new("__askama_let_block", proc_macro2::Span::call_site())
}
fn var_values() -> Ident {
syn::Ident::new("__askama_values", proc_macro2::Span::call_site())
}

View File

@ -31,7 +31,7 @@ use winnow::{LocatingSlice, ModalParser, ModalResult, Parser, Stateful};
use crate::ascii_str::{AsciiChar, AsciiStr};
pub use crate::expr::{AssociatedItem, Expr, Filter, PathComponent, TyGenerics};
pub use crate::node::Node;
pub use crate::node::{LetValueOrBlock, Node};
pub use crate::target::{NamedTarget, Target};
mod _parsed {

View File

@ -1239,11 +1239,17 @@ impl<'a: 'l, 'l> Declare<'a> {
}
}
#[derive(Debug, PartialEq)]
pub enum LetValueOrBlock<'a> {
Value(WithSpan<Box<Expr<'a>>>),
Block { nodes: Vec<Box<Node<'a>>>, ws: Ws },
}
#[derive(Debug, PartialEq)]
pub struct Let<'a> {
pub ws: Ws,
pub var: Target<'a>,
pub val: Option<WithSpan<Box<Expr<'a>>>>,
pub val: LetValueOrBlock<'a>,
pub is_mutable: bool,
}
@ -1322,11 +1328,57 @@ impl<'a: 'l, 'l> Let<'a> {
);
}
if let Some(val) = val {
return Ok(Box::new(Node::Let(WithSpan::new(
Let {
ws: Ws(pws, nws),
var,
val: LetValueOrBlock::Value(val),
is_mutable: is_mut.is_some(),
},
span,
))));
}
// We do this operation
if block_end.parse_next(i).is_err() {
return Err(
ErrorContext::unclosed("block", i.state.syntax.block_end, Span::new(span)).cut(),
);
}
let (keyword, end_keyword) = if tag == "let" {
("let", "endlet")
} else {
("set", "endset")
};
let keyword_span = Span::new(span.clone());
let mut end = cut_node(
Some(keyword),
(
Node::many,
cut_node(
Some(keyword),
(
|i: &mut _| check_block_start(i, keyword_span, keyword, end_keyword),
opt(Whitespace::parse),
end_node(keyword, end_keyword),
opt(Whitespace::parse),
),
),
),
);
let (nodes, (_, pws2, _, nws2)) = end.parse_next(i)?;
Ok(Box::new(Node::Let(WithSpan::new(
Let {
ws: Ws(pws, nws),
var,
val,
val: LetValueOrBlock::Block {
nodes,
ws: Ws(pws2, nws2),
},
is_mutable: is_mut.is_some(),
},
span,

View File

@ -5,8 +5,8 @@ use winnow::{LocatingSlice, Parser};
use crate::expr::BinOp;
use crate::node::{Let, Lit, Raw, Whitespace, Ws};
use crate::{
Ast, Expr, Filter, InnerSyntax, InputStream, Level, Node, Num, PathComponent, PathOrIdentifier,
State, StrLit, Syntax, SyntaxBuilder, Target, WithSpan,
Ast, Expr, Filter, InnerSyntax, InputStream, LetValueOrBlock, Level, Node, Num, PathComponent,
PathOrIdentifier, State, StrLit, Syntax, SyntaxBuilder, Target, WithSpan,
};
fn as_path<'a>(path: &'a [&'a str]) -> Vec<PathComponent<'a>> {
@ -1085,8 +1085,12 @@ fn fuzzed_comment_depth() {
fn let_set() {
let syntax = Syntax::default();
assert_eq!(
Ast::from_str("{% let a %}", None, &syntax).unwrap().nodes(),
Ast::from_str("{% set a %}", None, &syntax).unwrap().nodes(),
Ast::from_str("{% let a = 1 %}", None, &syntax)
.unwrap()
.nodes(),
Ast::from_str("{% set a = 1 %}", None, &syntax)
.unwrap()
.nodes(),
);
}
@ -1655,7 +1659,9 @@ fn regression_tests_span_change() {
var: Target::Array(WithSpan::no_span(vec![Target::Placeholder(
WithSpan::no_span(())
)])),
val: Some(WithSpan::no_span(Box::new(Expr::Array(vec![int_lit("2")])))),
val: LetValueOrBlock::Value(WithSpan::no_span(Box::new(Expr::Array(vec![int_lit(
"2"
)])))),
is_mutable: false,
})))],
);
@ -1667,7 +1673,9 @@ fn regression_tests_span_change() {
[Box::new(Node::Let(WithSpan::no_span(Let {
ws: Ws(Some(Whitespace::Suppress), Some(Whitespace::Suppress)),
var: Target::Placeholder(WithSpan::no_span(())),
val: Some(WithSpan::no_span(Box::new(Expr::Array(vec![int_lit("2")])))),
val: LetValueOrBlock::Value(WithSpan::no_span(Box::new(Expr::Array(vec![int_lit(
"2"
)])))),
is_mutable: false,
})))],
);

View File

@ -91,6 +91,16 @@ you need it to be mutable #}
For compatibility with Jinja, `set` can be used in place of `let`.
### Let/set blocks
You can create a variable and initialize it with a block computed string:
```jinja
{% let x %}
{{ crate::some_function() }} = {{ a * b}}
{% endlet %}
```
### Set variable values later
If you want to create a variable but set its value based on a condition, you can

View File

@ -1,4 +1,4 @@
{% let val -%}
{% decl val -%}
{% if cond -%}
{% let val = "foo" -%}
{% else -%}

View File

@ -1,5 +1,5 @@
{%- let a = 1 -%}
{%- let b -%}
{%- decl b -%}
{%- if cond -%}
{%- let b = 22 -%}

View File

@ -535,7 +535,7 @@ fn test_default_with_forward_declaration() {
#[derive(Template)]
#[template(
source = "\
{% let var %}\
{% decl var %}\
{{ var | default(\"unknown\") }}\
{% if true %}{% let var = 42 %}{% endif %}",
ext = "html"
@ -547,7 +547,7 @@ fn test_default_with_forward_declaration() {
#[derive(Template)]
#[template(
source = "\
{% let var %}{# shadowing happens here #}\
{% decl var %}{# shadowing happens here #}\
{{ var | default(\"unknown\") }}\
{% if true %}{% let var = 42 %}{% endif %}",
ext = "html"
@ -568,7 +568,7 @@ fn test_defined_or_with_forward_declaration() {
#[derive(Template)]
#[template(
source = "\
{% let var %}\
{% decl var %}\
{{ var | defined_or(\"unknown\") }}\
{% if true %}{% let var = 42 %}{% endif %}",
ext = "html"
@ -580,7 +580,7 @@ fn test_defined_or_with_forward_declaration() {
#[derive(Template)]
#[template(
source = "\
{% let var %}{# shadowing happens here #}\
{% decl var %}{# shadowing happens here #}\
{{ var | defined_or(\"unknown\") }}\
{% if true %}{% let var = 42 %}{% endif %}",
ext = "html"

View File

@ -5,7 +5,7 @@ use askama::Template;
fn let_macro() {
#[derive(Template)]
#[template(
source = r#"{%- let x -%}
source = r#"{%- decl x -%}
{%- if y -%}
{%- let x = String::new() %}
{%- else -%}
@ -53,3 +53,20 @@ fn underscore_ident2() {
assert_eq!(X.render().unwrap(), "hey\nhoy\nmatched");
}
#[test]
fn let_block() {
#[derive(Template)]
#[template(
source = r#"
{%- set navigation %}{{b}}: c{% endset -%}
{{ navigation -}}
"#,
ext = "txt"
)]
struct Foo {
b: u32,
}
assert_eq!(Foo { b: 0 }.render().unwrap(), "0: c");
}

View File

@ -17,5 +17,22 @@ use askama::Template;
)]
struct BlockInFilter;
#[derive(Template)]
#[template(
source = r#"{% extends "html-base.html" %}
{%- block body -%}
<h1>Metadata</h1>
{% let x %}
{% block title %}New title{% endblock %}
a b
{% endlet %}
{%- endblock body %}
"#,
ext = "html"
)]
struct BlockInLetBlock;
fn main() {
}

View File

@ -12,3 +12,18 @@ error: cannot have a block inside a filter block
14 | | {%- endblock body %}
15 | | "#,
| |__^
error: cannot have a block inside a let/set block
--> BlockInLetBlock.html:7:11
"block title %}New title{% endblock %}\n a b\n {% endlet %}\n{%- endblock "...
--> tests/ui/block_in_filter_block.rs:22:14
|
22 | source = r#"{% extends "html-base.html" %}
| ______________^
23 | |
24 | | {%- block body -%}
25 | | <h1>Metadata</h1>
... |
31 | | {%- endblock body %}
32 | | "#,
| |__^

View File

@ -32,6 +32,18 @@ struct Node3;
#[template(source = "{% let x -%", ext = "txt")]
struct Node4;
#[derive(Template)]
#[template(source = "{% let x %}{% endlet", ext = "txt")]
struct Node5;
#[derive(Template)]
#[template(source = "{% let x %}{% endset %}", ext = "txt")]
struct Node6;
#[derive(Template)]
#[template(source = "{% set x %}{% endlet %}", ext = "txt")]
struct Node7;
#[derive(Template)]
#[template(source = "{# comment", ext = "txt")]
struct Comment1;

View File

@ -31,65 +31,89 @@ error: failed to parse template source
| ^^^^^^^^^^^^
error: unclosed block, missing "%}"
--> <source attribute>:1:0
"{% let x"
--> <source attribute>:1:3
"let x"
--> tests/ui/unclosed-nodes.rs:20:21
|
20 | #[template(source = "{% let x", ext = "txt")]
| ^^^^^^^^^^
error: unclosed block, missing "%}"
--> <source attribute>:1:0
"{% let x "
--> <source attribute>:1:3
"let x "
--> tests/ui/unclosed-nodes.rs:24:21
|
24 | #[template(source = "{% let x ", ext = "txt")]
| ^^^^^^^^^^^
error: unclosed block, missing "%}"
--> <source attribute>:1:0
"{% let x -"
--> <source attribute>:1:3
"let x -"
--> tests/ui/unclosed-nodes.rs:28:21
|
28 | #[template(source = "{% let x -", ext = "txt")]
| ^^^^^^^^^^^^
error: failed to parse template source
--> <source attribute>:1:10
"%"
error: unclosed block, missing "%}"
--> <source attribute>:1:3
"let x -%"
--> tests/ui/unclosed-nodes.rs:32:21
|
32 | #[template(source = "{% let x -%", ext = "txt")]
| ^^^^^^^^^^^^^
error: unclosed block, missing "%}"
--> <source attribute>:1:0
"{% let x %}{% endlet"
--> tests/ui/unclosed-nodes.rs:36:21
|
36 | #[template(source = "{% let x %}{% endlet", ext = "txt")]
| ^^^^^^^^^^^^^^^^^^^^^^
error: expected `endlet` to terminate `let` node, found `endset`
--> <source attribute>:1:14
"endset %}"
--> tests/ui/unclosed-nodes.rs:40:21
|
40 | #[template(source = "{% let x %}{% endset %}", ext = "txt")]
| ^^^^^^^^^^^^^^^^^^^^^^^^^
error: expected `endset` to terminate `set` node, found `endlet`
--> <source attribute>:1:14
"endlet %}"
--> tests/ui/unclosed-nodes.rs:44:21
|
44 | #[template(source = "{% set x %}{% endlet %}", ext = "txt")]
| ^^^^^^^^^^^^^^^^^^^^^^^^^
error: unclosed comment, missing "#}"
--> <source attribute>:1:0
"{# comment"
--> tests/ui/unclosed-nodes.rs:36:21
--> tests/ui/unclosed-nodes.rs:48:21
|
36 | #[template(source = "{# comment", ext = "txt")]
48 | #[template(source = "{# comment", ext = "txt")]
| ^^^^^^^^^^^^
error: unclosed comment, missing "#}"
--> <source attribute>:1:0
"{# comment "
--> tests/ui/unclosed-nodes.rs:40:21
--> tests/ui/unclosed-nodes.rs:52:21
|
40 | #[template(source = "{# comment ", ext = "txt")]
52 | #[template(source = "{# comment ", ext = "txt")]
| ^^^^^^^^^^^^^
error: unclosed comment, missing "#}"
--> <source attribute>:1:0
"{# comment -"
--> tests/ui/unclosed-nodes.rs:44:21
--> tests/ui/unclosed-nodes.rs:56:21
|
44 | #[template(source = "{# comment -", ext = "txt")]
56 | #[template(source = "{# comment -", ext = "txt")]
| ^^^^^^^^^^^^^^
error: unclosed comment, missing "#}"
--> <source attribute>:1:0
"{# comment -#"
--> tests/ui/unclosed-nodes.rs:48:21
--> tests/ui/unclosed-nodes.rs:60:21
|
48 | #[template(source = "{# comment -#", ext = "txt")]
60 | #[template(source = "{# comment -#", ext = "txt")]
| ^^^^^^^^^^^^^^^

View File

@ -30,14 +30,6 @@ error: node `when` was not expected in the current context
34 | /// ```askama
| ^^^^^^^^^^^^^
error: unexpected closing tag `endlet`
--> <source attribute>:1:21
"endlet %}"
--> tests/ui/unexpected-tag.rs:43:1
|
43 | /// ```askama
| ^^^^^^^^^^^^^
error: unknown node `syntax`
--> <source attribute>:1:3
"syntax error %}"

View File

@ -123,7 +123,7 @@ fn test_decl_range() {
fn test_decl_assign_range() {
#[derive(Template)]
#[template(
source = "{% let x %}{% let x = 1 %}{% for x in x..=x %}{{ x }}{% endfor %}",
source = "{% decl x %}{% let x = 1 %}{% for x in x..=x %}{{ x }}{% endfor %}",
ext = "txt"
)]
struct DeclAssignRange;