From b402936db37b930d5e4b47b173c682c1b8bc63c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Kijewski?= Date: Thu, 17 Apr 2025 17:15:00 +0200 Subject: [PATCH] generator: add named arguments for filters --- askama_derive/src/generator/expr.rs | 1 + askama_derive/src/generator/filter.rs | 444 +++++++++++++----- askama_derive/src/generator/node.rs | 4 +- askama_parser/src/expr.rs | 10 +- askama_parser/src/lib.rs | 2 +- testing/tests/named_filter_arguments.rs | 188 ++++++++ testing/tests/ui/json-too-many-args.stderr | 6 +- testing/tests/ui/named_filter_arguments.rs | 48 ++ .../tests/ui/named_filter_arguments.stderr | 39 ++ testing/tests/ui/no-such-escaper.stderr | 2 +- testing/tests/ui/truncate.stderr | 8 +- 11 files changed, 625 insertions(+), 127 deletions(-) create mode 100644 testing/tests/named_filter_arguments.rs create mode 100644 testing/tests/ui/named_filter_arguments.rs create mode 100644 testing/tests/ui/named_filter_arguments.stderr diff --git a/askama_derive/src/generator/expr.rs b/askama_derive/src/generator/expr.rs index d535fd84..f6db6b31 100644 --- a/askama_derive/src/generator/expr.rs +++ b/askama_derive/src/generator/expr.rs @@ -65,6 +65,7 @@ impl<'a> Generator<'a, '_> { Expr::As(ref expr, target) => self.visit_as(ctx, buf, expr, target)?, Expr::Concat(ref exprs) => self.visit_concat(ctx, buf, exprs)?, Expr::LetCond(ref cond) => self.visit_let_cond(ctx, buf, cond)?, + Expr::ArgumentPlaceholder => DisplayWrap::Unwrapped, }) } diff --git a/askama_derive/src/generator/filter.rs b/askama_derive/src/generator/filter.rs index aa9be808..cb27d43a 100644 --- a/askama_derive/src/generator/filter.rs +++ b/askama_derive/src/generator/filter.rs @@ -1,11 +1,13 @@ use std::borrow::Cow; +use std::fmt::{self, Write}; +use std::mem::replace; use parser::{Expr, IntKind, Num, Span, StrLit, StrPrefix, TyGenerics, WithSpan}; use super::{DisplayWrap, Generator, TargetIsize, TargetUsize}; use crate::heritage::Context; use crate::integration::Buffer; -use crate::{CompileError, MsgValidEscapers}; +use crate::{CompileError, MsgValidEscapers, fmt_left, fmt_right}; impl<'a> Generator<'a, '_> { pub(super) fn visit_filter( @@ -60,8 +62,9 @@ impl<'a> Generator<'a, '_> { name: &str, args: &[WithSpan<'a, Expr<'a>>], generics: &[WithSpan<'a, TyGenerics<'a>>], - _node: Span<'_>, + node: Span<'_>, ) -> Result { + ensure_no_named_arguments(ctx, name, args, node)?; buf.write(format_args!("filters::{name}")); self.visit_call_generics(buf, generics); buf.write('('); @@ -111,10 +114,11 @@ impl<'a> Generator<'a, '_> { node: Span<'_>, ) -> Result { ensure_no_generics(ctx, name, node, generics)?; + let arg = no_arguments(ctx, name, args)?; buf.write(format_args!("askama::filters::{name}")); self.visit_call_generics(buf, generics); buf.write('('); - self.visit_args(ctx, buf, args)?; + self.visit_arg(ctx, buf, arg)?; buf.write(")?"); Ok(DisplayWrap::Unwrapped) } @@ -154,11 +158,12 @@ impl<'a> Generator<'a, '_> { )); } + let arg = no_arguments(ctx, name, args)?; // Both filters return HTML-safe strings. buf.write(format_args!( "askama::filters::HtmlSafeOutput(askama::filters::{name}(", )); - self.visit_args(ctx, buf, args)?; + self.visit_arg(ctx, buf, arg)?; buf.write(")?)"); Ok(DisplayWrap::Unwrapped) } @@ -171,15 +176,10 @@ impl<'a> Generator<'a, '_> { node: Span<'_>, ) -> Result { ensure_filter_has_feature_alloc(ctx, "wordcount", node)?; - if args.len() != 1 { - return Err(ctx.generate_error( - format_args!("unexpected argument(s) in `wordcount` filter"), - node, - )); - } + let arg = no_arguments(ctx, "wordcount", args)?; buf.write("match askama::filters::wordcount(&("); - self.visit_args(ctx, buf, args)?; + self.visit_arg(ctx, buf, arg)?; buf.write( ")) {\ expr0 => {\ @@ -201,12 +201,13 @@ impl<'a> Generator<'a, '_> { args: &[WithSpan<'a, Expr<'a>>], _node: Span<'_>, ) -> Result { + let arg = no_arguments(ctx, "humansize", args)?; // All filters return numbers, and any default formatted number is HTML safe. buf.write(format_args!( "askama::filters::HtmlSafeOutput(askama::filters::filesizeformat(\ askama::helpers::get_primitive_value(&(" )); - self.visit_args(ctx, buf, args)?; + self.visit_arg(ctx, buf, arg)?; buf.write(")) as askama::helpers::core::primitive::f32)?)"); Ok(DisplayWrap::Unwrapped) } @@ -228,17 +229,20 @@ impl<'a> Generator<'a, '_> { prefix: None, content: "s", })); + const ARGUMENTS: &[&FilterArgument; 3] = &[ + FILTER_SOURCE, + &FilterArgument { + name: "sg", + default_value: Some(SINGULAR), + }, + &FilterArgument { + name: "pl", + default_value: Some(PLURAL), + }, + ]; + + let [count, sg, pl] = collect_filter_args(ctx, "pluralize", node, args, ARGUMENTS)?; - let (count, sg, pl) = match args { - [count] => (count, SINGULAR, PLURAL), - [count, sg] => (count, sg, PLURAL), - [count, sg, pl] => (count, sg, pl), - _ => { - return Err( - ctx.generate_error("unexpected argument(s) in `pluralize` filter", node) - ); - } - }; if let Some(is_singular) = expr_is_int_lit_plus_minus_one(count) { let value = if is_singular { sg } else { pl }; self.visit_auto_escaped_arg(ctx, buf, value)?; @@ -293,16 +297,11 @@ impl<'a> Generator<'a, '_> { node: Span<'_>, ) -> Result { ensure_filter_has_feature_alloc(ctx, name, node)?; - if args.len() != 1 { - return Err(ctx.generate_error( - format_args!("unexpected argument(s) in `{name}` filter"), - node, - )); - } + let arg = no_arguments(ctx, name, args)?; buf.write(format_args!( "askama::filters::{name}(&(&&askama::filters::AutoEscaper::new(&(", )); - self.visit_args(ctx, buf, args)?; + self.visit_arg(ctx, buf, arg)?; // The input is always HTML escaped, regardless of the selected escaper: buf.write("), askama::filters::Html)).askama_auto_escape()?)?"); // The output is marked as HTML safe, not safe in all contexts: @@ -314,12 +313,9 @@ impl<'a> Generator<'a, '_> { ctx: &Context<'_>, buf: &mut Buffer, args: &[WithSpan<'a, Expr<'a>>], - node: Span<'_>, + _node: Span<'_>, ) -> Result { - let arg = match args { - [arg] => arg, - _ => return Err(ctx.generate_error("unexpected argument(s) in `as_ref` filter", node)), - }; + let arg = no_arguments(ctx, "ref", args)?; buf.write('&'); self.visit_expr(ctx, buf, arg)?; Ok(DisplayWrap::Unwrapped) @@ -330,12 +326,9 @@ impl<'a> Generator<'a, '_> { ctx: &Context<'_>, buf: &mut Buffer, args: &[WithSpan<'a, Expr<'a>>], - node: Span<'_>, + _node: Span<'_>, ) -> Result { - let arg = match args { - [arg] => arg, - _ => return Err(ctx.generate_error("unexpected argument(s) in `deref` filter", node)), - }; + let arg = no_arguments(ctx, "deref", args)?; buf.write('*'); self.visit_expr(ctx, buf, arg)?; Ok(DisplayWrap::Unwrapped) @@ -348,6 +341,14 @@ impl<'a> Generator<'a, '_> { args: &[WithSpan<'a, Expr<'a>>], node: Span<'_>, ) -> Result { + const ARGUMENTS: &[&FilterArgument; 2] = &[ + FILTER_SOURCE, + &FilterArgument { + name: "indent", + default_value: Some(ARGUMENT_PLACEHOLDER), + }, + ]; + if cfg!(not(feature = "serde_json")) { return Err(ctx.generate_error( "the `json` filter requires the `serde_json` feature to be enabled", @@ -355,14 +356,18 @@ impl<'a> Generator<'a, '_> { )); } - let filter = match args.len() { - 1 => "json", - 2 => "json_pretty", - _ => return Err(ctx.generate_error("unexpected argument(s) in `json` filter", node)), - }; - buf.write(format_args!("askama::filters::{filter}(")); - self.visit_args(ctx, buf, args)?; - buf.write(")?"); + let [value, indent] = collect_filter_args(ctx, "json", node, args, ARGUMENTS)?; + if is_argument_placeholder(indent) { + buf.write(format_args!("askama::filters::json(")); + self.visit_arg(ctx, buf, value)?; + buf.write(")?"); + } else { + buf.write(format_args!("askama::filters::json_pretty(")); + self.visit_arg(ctx, buf, value)?; + buf.write(','); + self.visit_arg(ctx, buf, indent)?; + buf.write(")?"); + } Ok(DisplayWrap::Unwrapped) } @@ -375,18 +380,25 @@ impl<'a> Generator<'a, '_> { ) -> Result { const FALSE: &WithSpan<'static, Expr<'static>> = &WithSpan::new_without_span(Expr::BoolLit(false)); + const ARGUMENTS: &[&FilterArgument; 4] = &[ + FILTER_SOURCE, + &FilterArgument { + name: "width", + default_value: None, + }, + &FilterArgument { + name: "first", + default_value: Some(FALSE), + }, + &FilterArgument { + name: "blank", + default_value: Some(FALSE), + }, + ]; ensure_filter_has_feature_alloc(ctx, "indent", node)?; - let (source, indent, first, blank) = - match args { - [source, indent] => (source, indent, FALSE, FALSE), - [source, indent, first] => (source, indent, first, FALSE), - [source, indent, first, blank] => (source, indent, first, blank), - _ => return Err(ctx.generate_error( - "filter `indent` needs a `width` argument, and can have two optional arguments", - node, - )), - }; + let [source, indent, first, blank] = + collect_filter_args(ctx, "indent", node, args, ARGUMENTS)?; buf.write("askama::filters::indent("); self.visit_arg(ctx, buf, source)?; buf.write(","); @@ -404,13 +416,11 @@ impl<'a> Generator<'a, '_> { ctx: &Context<'_>, buf: &mut Buffer, args: &[WithSpan<'a, Expr<'a>>], - node: Span<'_>, + _node: Span<'_>, ) -> Result { - if args.len() != 1 { - return Err(ctx.generate_error("unexpected argument(s) in `safe` filter", node)); - } + let arg = no_arguments(ctx, "safe", args)?; buf.write("askama::filters::safe("); - self.visit_args(ctx, buf, args)?; + self.visit_arg(ctx, buf, arg)?; buf.write(format_args!(", {})?", self.input.escaper)); Ok(DisplayWrap::Wrapped) } @@ -422,31 +432,37 @@ impl<'a> Generator<'a, '_> { args: &[WithSpan<'a, Expr<'a>>], node: Span<'_>, ) -> Result { - if args.len() > 2 { - return Err(ctx.generate_error("only two arguments allowed to escape filter", node)); - } - let opt_escaper = match args.get(1).map(|expr| &**expr) { - Some(Expr::StrLit(StrLit { prefix, content })) => { - if let Some(prefix) = prefix { - let kind = if *prefix == StrPrefix::Binary { - "slice" - } else { - "CStr" - }; - return Err(ctx.generate_error( - format_args!( - "invalid escaper `b{content:?}`. Expected a string, found a {kind}" - ), - args[1].span(), - )); - } - Some(content) - } - Some(_) => { + const ARGUMENTS: &[&FilterArgument; 2] = &[ + FILTER_SOURCE, + &FilterArgument { + name: "escaper", + default_value: Some(ARGUMENT_PLACEHOLDER), + }, + ]; + + let [source, opt_escaper] = collect_filter_args(ctx, "escape", node, args, ARGUMENTS)?; + let opt_escaper = if !is_argument_placeholder(opt_escaper) { + let Expr::StrLit(StrLit { prefix, content }) = **opt_escaper else { return Err(ctx.generate_error("invalid escaper type for escape filter", node)); + }; + if let Some(prefix) = prefix { + let kind = if prefix == StrPrefix::Binary { + "slice" + } else { + "CStr" + }; + return Err(ctx.generate_error( + format_args!( + "invalid escaper `b{content:?}`. Expected a string, found a {kind}" + ), + opt_escaper.span(), + )); } - None => None, + Some(content) + } else { + None }; + let escaper = match opt_escaper { Some(name) => self .input @@ -461,7 +477,8 @@ impl<'a> Generator<'a, '_> { .ok_or_else(|| { ctx.generate_error( format_args!( - "invalid escaper '{name}' for `escape` filter. {}", + "invalid escaper `{}` for `escape` filter. {}", + name.escape_debug(), MsgValidEscapers(&self.input.config.escapers), ), node, @@ -470,7 +487,7 @@ impl<'a> Generator<'a, '_> { None => self.input.escaper, }; buf.write("askama::filters::escape("); - self.visit_args(ctx, buf, &args[..1])?; + self.visit_arg(ctx, buf, source)?; buf.write(format_args!(", {escaper})?")); Ok(DisplayWrap::Wrapped) } @@ -483,6 +500,7 @@ impl<'a> Generator<'a, '_> { node: Span<'_>, ) -> Result { ensure_filter_has_feature_alloc(ctx, "format", node)?; + ensure_no_named_arguments(ctx, "format", args, node)?; if !args.is_empty() { if let Expr::StrLit(ref fmt) = *args[0] { buf.write("askama::helpers::alloc::format!("); @@ -495,7 +513,10 @@ impl<'a> Generator<'a, '_> { return Ok(DisplayWrap::Unwrapped); } } - Err(ctx.generate_error(r#"use filter format like `"a={} b={}"|format(a, b)`"#, node)) + Err(ctx.generate_error( + r#"use `format` filter like `"a={} b={}"|format(a, b)`"#, + node, + )) } fn visit_fmt_filter( @@ -505,18 +526,25 @@ impl<'a> Generator<'a, '_> { args: &[WithSpan<'a, Expr<'a>>], node: Span<'_>, ) -> Result { + const ARGUMENTS: &[&FilterArgument; 2] = &[ + FILTER_SOURCE, + &FilterArgument { + name: "format", + default_value: None, + }, + ]; + ensure_filter_has_feature_alloc(ctx, "fmt", node)?; - if let [_, arg2] = args { - if let Expr::StrLit(ref fmt) = **arg2 { - buf.write("askama::helpers::alloc::format!("); - self.visit_str_lit(buf, fmt); - buf.write(','); - self.visit_args(ctx, buf, &args[..1])?; - buf.write(')'); - return Ok(DisplayWrap::Unwrapped); - } - } - Err(ctx.generate_error(r#"use filter fmt like `value|fmt("{:?}")`"#, node)) + let [source, fmt] = collect_filter_args(ctx, "fmt", node, args, ARGUMENTS)?; + let Expr::StrLit(ref fmt) = **fmt else { + return Err(ctx.generate_error(r#"use `fmt` filter like `value|fmt("{:?}")`"#, node)); + }; + buf.write("askama::helpers::alloc::format!("); + self.visit_str_lit(buf, fmt); + buf.write(','); + self.visit_arg(ctx, buf, source)?; + buf.write(')'); + Ok(DisplayWrap::Unwrapped) } // Force type coercion on first argument to `join` filter (see #39). @@ -525,18 +553,21 @@ impl<'a> Generator<'a, '_> { ctx: &Context<'_>, buf: &mut Buffer, args: &[WithSpan<'a, Expr<'a>>], - _node: Span<'_>, + node: Span<'_>, ) -> Result { - buf.write("askama::filters::join((&"); - for (i, arg) in args.iter().enumerate() { - if i > 0 { - buf.write(", &"); - } - self.visit_expr(ctx, buf, arg)?; - if i == 0 { - buf.write(").into_iter()"); - } - } + const ARGUMENTS: &[&FilterArgument; 2] = &[ + FILTER_SOURCE, + &FilterArgument { + name: "separator", + default_value: None, + }, + ]; + + let [iterable, separator] = collect_filter_args(ctx, "join", node, args, ARGUMENTS)?; + buf.write("askama::filters::join((&("); + self.visit_arg(ctx, buf, iterable)?; + buf.write(")).into_iter(),"); + self.visit_arg(ctx, buf, separator)?; buf.write(")?"); Ok(DisplayWrap::Unwrapped) } @@ -569,14 +600,16 @@ impl<'a> Generator<'a, '_> { node: Span<'_>, name: &str, ) -> Result { - ensure_filter_has_feature_alloc(ctx, name, node)?; - let [arg, length] = args else { - return Err(ctx.generate_error( - format_args!("`{name}` filter needs one argument, the `length`"), - node, - )); - }; + const ARGUMENTS: &[&FilterArgument; 2] = &[ + FILTER_SOURCE, + &FilterArgument { + name: "length", + default_value: None, + }, + ]; + ensure_filter_has_feature_alloc(ctx, name, node)?; + let [arg, length] = collect_filter_args(ctx, name, node, args, ARGUMENTS)?; buf.write(format_args!("askama::filters::{name}(")); self.visit_arg(ctx, buf, arg)?; buf.write( @@ -697,6 +730,185 @@ fn expr_is_int_lit_plus_minus_one(expr: &WithSpan<'_, Expr<'_>>) -> Option } } +struct FilterArgument { + name: &'static str, + /// If set to `None`, then a value is needed. + /// If set to `Some(ARGUMENT_PLACEHOLDER)`, then no value has to be assigned. + /// If set to `Some(&WithSpan...)`, then this value will be used if no argument was supplied. + default_value: Option<&'static WithSpan<'static, Expr<'static>>>, +} + +/// Must be the first entry to `collect_filter_args()`'s argument `filter_args`. +const FILTER_SOURCE: &FilterArgument = &FilterArgument { + name: "", + default_value: None, +}; + +const ARGUMENT_PLACEHOLDER: &WithSpan<'_, Expr<'_>> = + &WithSpan::new_without_span(Expr::ArgumentPlaceholder); + +#[inline] +fn is_argument_placeholder(arg: &WithSpan<'_, Expr<'_>>) -> bool { + matches!(**arg, Expr::ArgumentPlaceholder) +} + +fn no_arguments<'a, 'b>( + ctx: &Context<'_>, + name: &str, + args: &'b [WithSpan<'a, Expr<'a>>], +) -> Result<&'b WithSpan<'a, Expr<'a>>, CompileError> { + match args { + [arg] => Ok(arg), + [_, arg, ..] => Err(ctx.generate_error( + format_args!("`{name}` filter does not have any arguments"), + arg.span(), + )), + _ => unreachable!(), + } +} + +#[inline] +fn collect_filter_args<'a, 'b, const N: usize>( + ctx: &Context<'_>, + name: &str, + node: Span<'_>, + input_args: &'b [WithSpan<'a, Expr<'a>>], + filter_args: &'static [&'static FilterArgument; N], +) -> Result<[&'b WithSpan<'a, Expr<'a>>; N], CompileError> { + let mut collected_args = [ARGUMENT_PLACEHOLDER; N]; + // rationale: less code duplication by implementing the bulk of the function non-generic + collect_filter_args_inner( + ctx, + name, + node, + input_args, + filter_args, + &mut collected_args, + )?; + Ok(collected_args) +} + +fn collect_filter_args_inner<'a, 'b>( + ctx: &Context<'_>, + name: &str, + node: Span<'_>, + input_args: &'b [WithSpan<'a, Expr<'a>>], + filter_args: &'static [&'static FilterArgument], + collected_args: &mut [&'b WithSpan<'a, Expr<'a>>], +) -> Result<(), CompileError> { + // invariant: the parser ensures that named arguments come after positional arguments + let mut arg_idx = 0; + for arg in input_args { + let (idx, value) = if let Expr::NamedArgument(arg_name, ref value) = **arg { + let Some(idx) = filter_args + .iter() + .enumerate() + .find_map(|(idx, arg)| (arg.name == arg_name).then_some(idx)) + else { + return Err(ctx.generate_error( + match filter_args.len() { + 1 => fmt_left!("`{name}` filter does not have any arguments"), + _ => fmt_right!( + "`{name}` filter does not have an argument `{}`{}", + arg_name.escape_debug(), + ItsArgumentsAre(filter_args), + ), + }, + arg.span(), + )); + }; + (idx, &**value) + } else { + let idx = arg_idx; + arg_idx += 1; + (idx, arg) + }; + + let Some(collected_arg) = collected_args.get_mut(idx) else { + return Err(ctx.generate_error( + format_args!( + "`{name}` filter accepts at most {} argument{}{}", + filter_args.len() - 1, + if filter_args.len() != 2 { "s" } else { "" }, + ItsArgumentsAre(filter_args), + ), + arg.span(), + )); + }; + if !is_argument_placeholder(replace(collected_arg, value)) { + return Err(ctx.generate_error( + format_args!( + "`{}` argument to `{}` filter was already set{}", + filter_args[idx].name.escape_debug(), + name.escape_debug(), + ItsArgumentsAre(filter_args), + ), + arg.span(), + )); + } + } + + for (&arg, collected) in filter_args.iter().zip(collected_args) { + if !is_argument_placeholder(collected) { + continue; + } else if let Some(default) = arg.default_value { + *collected = default; + } else { + return Err(ctx.generate_error( + format_args!( + "`{}` argument is missing when calling `{name}` filter{}", + arg.name.escape_debug(), + ItsArgumentsAre(filter_args), + ), + node, + )); + } + } + + Ok(()) +} + +struct ItsArgumentsAre(&'static [&'static FilterArgument]); + +impl fmt::Display for ItsArgumentsAre { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("; its arguments are: (")?; + for (idx, arg) in self.0.iter().enumerate() { + match idx { + 0 => continue, + 1 => {} + _ => f.write_str(", ")?, + } + if arg.default_value.is_some() { + write!(f, "[{}]", arg.name)?; + } else { + f.write_str(arg.name)?; + } + } + f.write_char(')') + } +} + +fn ensure_no_named_arguments( + ctx: &Context<'_>, + name: &str, + args: &[WithSpan<'_, Expr<'_>>], + node: Span<'_>, +) -> Result<(), CompileError> { + for arg in args { + if is_argument_placeholder(arg) { + return Err(ctx.generate_error( + format_args!( + "`{}` filter cannot accept named arguments", + name.escape_debug() + ), + node, + )); + } + } + Ok(()) +} + // These built-in filters take no arguments, no generics, and are not feature gated. const BUILTIN_FILTERS: &[&str] = &[]; diff --git a/askama_derive/src/generator/node.rs b/askama_derive/src/generator/node.rs index d9a54dbd..d69d90f9 100644 --- a/askama_derive/src/generator/node.rs +++ b/askama_derive/src/generator/node.rs @@ -207,7 +207,8 @@ impl<'a> Generator<'a, '_> { | Expr::FilterSource | Expr::As(_, _) | Expr::Concat(_) - | Expr::LetCond(_) => { + | Expr::LetCond(_) + | Expr::ArgumentPlaceholder => { *only_contains_is_defined = false; (EvaluatedResult::Unknown, WithSpan::new(expr, span)) } @@ -1559,5 +1560,6 @@ fn is_cacheable(expr: &WithSpan<'_, Expr<'_>>) -> bool { Expr::RustMacro(_, _) => false, // Should never be encountered: Expr::FilterSource => unreachable!("FilterSource in expression?"), + Expr::ArgumentPlaceholder => unreachable!("ExpressionPlaceholder in expression?"), } } diff --git a/askama_parser/src/expr.rs b/askama_parser/src/expr.rs index 6056aad4..b235911e 100644 --- a/askama_parser/src/expr.rs +++ b/askama_parser/src/expr.rs @@ -93,6 +93,10 @@ fn check_expr<'a>( } Ok(()) } + Expr::ArgumentPlaceholder => Err(winnow::error::ErrMode::Cut(ErrorContext::new( + "unreachable", + expr.span, + ))), } } @@ -137,6 +141,9 @@ pub enum Expr<'a> { Concat(Vec>>), /// If you have `&& let Some(y)`, this variant handles it. LetCond(Box>>), + /// This variant should never be used directly. + /// It is used for the handling of named arguments in the generator, esp. with filters. + ArgumentPlaceholder, } impl<'a> Expr<'a> { @@ -526,7 +533,8 @@ impl<'a> Expr<'a> { | Self::BinOp(_, _, _) | Self::Path(_) | Self::Concat(_) - | Self::LetCond(_) => false, + | Self::LetCond(_) + | Self::ArgumentPlaceholder => false, } } } diff --git a/askama_parser/src/lib.rs b/askama_parser/src/lib.rs index 2e616c9b..986c97b0 100644 --- a/askama_parser/src/lib.rs +++ b/askama_parser/src/lib.rs @@ -1037,7 +1037,7 @@ fn filter<'a>( cut_err(( ws(identifier), opt(|i: &mut _| expr::call_generics(i, level)).map(|generics| generics.unwrap_or_default()), - opt(|i: &mut _| Expr::arguments(i, level, false)), + opt(|i: &mut _| Expr::arguments(i, level, true)), )) .parse_next(i) } diff --git a/testing/tests/named_filter_arguments.rs b/testing/tests/named_filter_arguments.rs new file mode 100644 index 00000000..5ac4637e --- /dev/null +++ b/testing/tests/named_filter_arguments.rs @@ -0,0 +1,188 @@ +use askama::Template; + +#[test] +fn test_pluralize_two_positional_args() { + #[derive(Template)] + #[template( + source = r#"I have {{ count }} butterfl{{ count | pluralize("y", "ies") }}."#, + ext = "txt" + )] + struct CountButterflies { + count: usize, + } + + assert_eq!( + CountButterflies { count: 0 }.render().unwrap(), + "I have 0 butterflies." + ); + assert_eq!( + CountButterflies { count: 1 }.render().unwrap(), + "I have 1 butterfly." + ); + assert_eq!( + CountButterflies { count: 2 }.render().unwrap(), + "I have 2 butterflies." + ); +} + +#[test] +fn test_pluralize_positional_and_named() { + #[derive(Template)] + #[template( + source = r#"I have {{ count }} butterfl{{ count | pluralize("y", pl = "ies") }}."#, + ext = "txt" + )] + struct CountButterflies { + count: usize, + } + + assert_eq!( + CountButterflies { count: 0 }.render().unwrap(), + "I have 0 butterflies." + ); + assert_eq!( + CountButterflies { count: 1 }.render().unwrap(), + "I have 1 butterfly." + ); + assert_eq!( + CountButterflies { count: 2 }.render().unwrap(), + "I have 2 butterflies." + ); +} + +#[test] +fn test_pluralize_named_reordered() { + #[derive(Template)] + #[template( + source = r#"I have {{ count }} butterfl{{ count | pluralize(pl = "ies", sg = "y") }}."#, + ext = "txt" + )] + struct CountButterflies { + count: usize, + } + + assert_eq!( + CountButterflies { count: 0 }.render().unwrap(), + "I have 0 butterflies." + ); + assert_eq!( + CountButterflies { count: 1 }.render().unwrap(), + "I have 1 butterfly." + ); + assert_eq!( + CountButterflies { count: 2 }.render().unwrap(), + "I have 2 butterflies." + ); +} + +#[test] +fn test_pluralize_defaulted_and_named() { + #[derive(Template)] + #[template( + source = r#"I have {{ count }} potato{{ count | pluralize(pl = "es") }}."#, + ext = "txt" + )] + struct CountPotatoes { + count: usize, + } + + assert_eq!( + CountPotatoes { count: 0 }.render().unwrap(), + "I have 0 potatoes." + ); + assert_eq!( + CountPotatoes { count: 1 }.render().unwrap(), + "I have 1 potato." + ); + assert_eq!( + CountPotatoes { count: 2 }.render().unwrap(), + "I have 2 potatoes." + ); +} + +#[test] +fn test_ident() { + const TEXT: &str = "\ + Lorem Ipsum has been the industry's\n\ + \n\ + standard dummy text ever since the 1500s"; + + #[derive(Template)] + #[template(source = r#"<{{ text | indent("$$", blank = true) }}>"#, ext = "txt")] + struct IdentBlank<'a> { + text: &'a str, + } + + assert_eq!( + IdentBlank { text: TEXT }.render().unwrap(), + "\ + " + ); + + #[derive(Template)] + #[template(source = r#"<{{ text | indent("$$", first = true) }}>"#, ext = "txt")] + struct IdentFirst<'a> { + text: &'a str, + } + + assert_eq!( + IdentFirst { text: TEXT }.render().unwrap(), + "\ + <$$Lorem Ipsum has been the industry's\n\ + \n\ + $$standard dummy text ever since the 1500s>" + ); + + #[derive(Template)] + #[template( + source = r#"<{{ text | indent("$$", blank = true, first = true) }}>"#, + ext = "txt" + )] + struct IdentBoth<'a> { + text: &'a str, + } + + assert_eq!( + IdentBoth { text: TEXT }.render().unwrap(), + "\ + <$$Lorem Ipsum has been the industry's\n\ + $$\n\ + $$standard dummy text ever since the 1500s>" + ); + + #[derive(Template)] + #[template( + source = r#"<{{ text | indent("$$", first = true, blank = true) }}>"#, + ext = "txt" + )] + struct IdentBoth2<'a> { + text: &'a str, + } + + assert_eq!( + IdentBoth2 { text: TEXT }.render().unwrap(), + "\ + <$$Lorem Ipsum has been the industry's\n\ + $$\n\ + $$standard dummy text ever since the 1500s>" + ); + + #[derive(Template)] + #[template( + source = r#"<{{ text | indent(first = true, width = "$$", blank = true) }}>"#, + ext = "txt" + )] + struct IdentBoth3<'a> { + text: &'a str, + } + + assert_eq!( + IdentBoth3 { text: TEXT }.render().unwrap(), + "\ + <$$Lorem Ipsum has been the industry's\n\ + $$\n\ + $$standard dummy text ever since the 1500s>" + ); +} diff --git a/testing/tests/ui/json-too-many-args.stderr b/testing/tests/ui/json-too-many-args.stderr index c2e59678..3f444a6a 100644 --- a/testing/tests/ui/json-too-many-args.stderr +++ b/testing/tests/ui/json-too-many-args.stderr @@ -1,6 +1,6 @@ -error: unexpected argument(s) in `json` filter - --> OneTwoThree.txt:1:3 - "1|json(2, 3) }}" +error: `json` filter accepts at most 1 argument; its arguments are: ([indent]) + --> OneTwoThree.txt:1:13 + "3) }}" --> tests/ui/json-too-many-args.rs:6:34 | 6 | #[template(ext = "txt", source = "{{ 1|json(2, 3) }}")] diff --git a/testing/tests/ui/named_filter_arguments.rs b/testing/tests/ui/named_filter_arguments.rs new file mode 100644 index 00000000..443e9b79 --- /dev/null +++ b/testing/tests/ui/named_filter_arguments.rs @@ -0,0 +1,48 @@ +use askama::Template; + +#[derive(Template)] +#[template( + source = r#"I have {{ count }} butterfl{{ count | pluralize(pl = "ies", "y") }}."#, + ext = "txt" +)] +struct PositionalAfterNamed { + count: usize, +} + +#[derive(Template)] +#[template( + source = r#"I have {{ count }} butterfl{{ count | pluralize(pl = "y", pl = "ies") }}."#, + ext = "txt" +)] +struct NamedRepeated { + count: usize, +} + +#[derive(Template)] +#[template( + source = r#"I have {{ count }} butterfl{{ count | pluralize("y", sg = "ies") }}."#, + ext = "txt" +)] +struct NamedButAlreadyPositional { + count: usize, +} + +#[derive(Template)] +#[template( + source = r#"I have {{ count }} butterfl{{ count | pluralize("y", plural = "ies") }}."#, + ext = "txt" +)] +struct NoSuchNamedArgument { + count: usize, +} + +#[derive(Template)] +#[template( + source = r#"Scream: {{ message | uppercase(case = "upper") }}"#, + ext = "txt" +)] +struct NamedArgumentButNoArgumentExpected<'a> { + message: &'a str, +} + +fn main() {} diff --git a/testing/tests/ui/named_filter_arguments.stderr b/testing/tests/ui/named_filter_arguments.stderr new file mode 100644 index 00000000..1dc90593 --- /dev/null +++ b/testing/tests/ui/named_filter_arguments.stderr @@ -0,0 +1,39 @@ +error: named arguments must always be passed last + --> :1:47 + "(pl = \"ies\", \"y\") }}." + --> tests/ui/named_filter_arguments.rs:5:14 + | +5 | source = r#"I have {{ count }} butterfl{{ count | pluralize(pl = "ies", "y") }}."#, + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +error: named argument `pl` was passed more than once + --> :1:47 + "(pl = \"y\", pl = \"ies\") }}." + --> tests/ui/named_filter_arguments.rs:14:14 + | +14 | source = r#"I have {{ count }} butterfl{{ count | pluralize(pl = "y", pl = "ies") }}."#, + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +error: `sg` argument to `pluralize` filter was already set; its arguments are: ([sg], [pl]) + --> NamedButAlreadyPositional.txt:1:47 + "(\"y\", sg = \"ies\") }}." + --> tests/ui/named_filter_arguments.rs:23:14 + | +23 | source = r#"I have {{ count }} butterfl{{ count | pluralize("y", sg = "ies") }}."#, + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +error: `pluralize` filter does not have an argument `plural`; its arguments are: ([sg], [pl]) + --> NoSuchNamedArgument.txt:1:47 + "(\"y\", plural = \"ies\") }}." + --> tests/ui/named_filter_arguments.rs:32:14 + | +32 | source = r#"I have {{ count }} butterfl{{ count | pluralize("y", plural = "ies") }}."#, + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +error: `uppercase` filter does not have any arguments + --> NamedArgumentButNoArgumentExpected.txt:1:30 + "(case = \"upper\") }}" + --> tests/ui/named_filter_arguments.rs:41:14 + | +41 | source = r#"Scream: {{ message | uppercase(case = "upper") }}"#, + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/testing/tests/ui/no-such-escaper.stderr b/testing/tests/ui/no-such-escaper.stderr index e2a040a0..315b375c 100644 --- a/testing/tests/ui/no-such-escaper.stderr +++ b/testing/tests/ui/no-such-escaper.stderr @@ -1,4 +1,4 @@ -error: invalid escaper 'latex' for `escape` filter. The available extensions are: "", "askama", "htm", "html", "j2", "jinja", "jinja2", "md", "none", "rinja", "svg", "txt", "xml", "yml" +error: invalid escaper `latex` for `escape` filter. The available extensions are: "", "askama", "htm", "html", "j2", "jinja", "jinja2", "md", "none", "rinja", "svg", "txt", "xml", "yml" --> LocalEscaper.html:1:38 "text|escape(\"latex\")}}`." --> tests/ui/no-such-escaper.rs:6:14 diff --git a/testing/tests/ui/truncate.stderr b/testing/tests/ui/truncate.stderr index 6ff8bf5e..fe946dfd 100644 --- a/testing/tests/ui/truncate.stderr +++ b/testing/tests/ui/truncate.stderr @@ -1,4 +1,4 @@ -error: filter `truncate` needs one argument, the `length` +error: `length` argument is missing when calling `truncate` filter; its arguments are: (length) --> NoArgument.html:1:3 "text | truncate }}" --> tests/ui/truncate.rs:4:21 @@ -6,9 +6,9 @@ error: filter `truncate` needs one argument, the `length` 4 | #[template(source = r#"{{ text | truncate }}"#, ext = "html")] | ^^^^^^^^^^^^^^^^^^^^^^^^^^ -error: filter `truncate` needs one argument, the `length` - --> TooManyArguments.html:1:3 - "text | truncate(length, extra) }}" +error: `truncate` filter accepts at most 1 argument; its arguments are: (length) + --> TooManyArguments.html:1:27 + "extra) }}" --> tests/ui/truncate.rs:17:21 | 17 | #[template(source = r#"{{ text | truncate(length, extra) }}"#, ext = "html")]