Repository: djc/askama
Pull request #345 (2020-07-08 17:11:52)

Add format filter that swaps the first two arguments.
Swapping the first two arguments allows a more natural filter usage: `{{ val | fmt("{:?}") }}`.

Happy to bikeshed the name.

djc: I don't see this adding much value -- it seems more likely to be confusing. Sorry!
couchand: If you don't want to add this, that's fine, but I hope you can at least take a moment to consider how this API is significantly more ergonomic.

For one thing, it better matches the mental model of other filters: data flows in from the left, configuration is passed as a param (see, for instance, `join`). It is also superior for composition with other filters, such as in  `{{ num | to_f64 | fmt("{:.2}") }}`. 
djc: Should have led with that! I hadn't realized the composition advantage. How do you feel about restricting it to a single argument (so no more arguments after the format string)?
couchand: Limiting it to one argument makes sense, it would get awfully confusing with more than one.

For that matter, I'm not sure there's much point to putting any static text in the format string in the context of a templating language. Would it be crazy to automatically wrap the format in curly braces? 

`{{ num | fmt(":.2") }}`

On the other hand, perhaps there would be a reason to include static text, to support composition with downstream filters:

`{{ price | fmt("${:.2}") | center }}`

Though that suggests a use-case for additional parameters (most useful if named) :

`{{ price | fmt("{symbol}{:.2}", symbol=currency) | center }}`

Although at that point you're probably better off writing a custom filter. 
djc: One of the core design choices in Askama is to make the templating language Rust-like. Eliding the braces seems antithetical to that.

Let's start with the simple two-argument version in this PR and we can discuss extending it once we run into a compelling use case.
couchand: Updated.  What do you think about the name?  I'd prefer to be explicit about the difference.  Perhaps `format_with` or `format_as` would clarify it?

As an aside, have you considered using `trybuild` to test compile fails?

Patch:
diff --git a/askama_shared/src/filters/mod.rs b/askama_shared/src/filters/mod.rs
index 2604ed9..7253e4b 100644
--- a/askama_shared/src/filters/mod.rs
+++ b/askama_shared/src/filters/mod.rs
@@ -58,13 +58,14 @@ const ENCODE_SET: &AsciiSet = &NON_ALPHANUMERIC
 // Askama or should refer to a local `filters` module. It should contain all the
 // filters shipped with Askama, even the optional ones (since optional inclusion
 // in the const vector based on features seems impossible right now).
-pub const BUILT_IN_FILTERS: [&str; 24] = [
+pub const BUILT_IN_FILTERS: [&str; 25] = [
     "abs",
     "capitalize",
     "center",
     "e",
     "escape",
     "filesizeformat",
+    "fmt",
     "format",
     "indent",
     "into_f64",
@@ -137,12 +138,33 @@ pub fn urlencode(s: &dyn fmt::Display) -> Result<String> {
     Ok(utf8_percent_encode(&s, ENCODE_SET).to_string())
 }
 
+/// Formats arguments according to the specified format
+///
+/// The *second* argument to this filter must be a string literal (as in normal
+/// Rust). The two arguments are passed through to the `format!()`
+/// [macro](https://doc.rust-lang.org/stable/std/macro.format.html) by
+/// the Askama code generator, but the order is swapped to support filter
+/// composition.
+///
+/// ```ignore
+/// {{ value | fmt("{:?}") }}
+/// ```
+///
+/// Compare with [format](./fn.format.html).
+pub fn fmt() {}
+
 /// Formats arguments according to the specified format
 ///
 /// The first argument to this filter must be a string literal (as in normal
 /// Rust). All arguments are passed through to the `format!()`
 /// [macro](https://doc.rust-lang.org/stable/std/macro.format.html) by
 /// the Askama code generator.
+///
+/// ```ignore
+/// {{ "{:?}{:?}" | format(value, other_value) }}
+/// ```
+///
+/// Compare with [fmt](./fn.fmt.html).
 pub fn format() {}
 
 /// Replaces line breaks in plain text with appropriate HTML
diff --git a/askama_shared/src/generator.rs b/askama_shared/src/generator.rs
index 82f68ff..e45acb6 100644
--- a/askama_shared/src/generator.rs
+++ b/askama_shared/src/generator.rs
@@ -999,6 +999,9 @@ impl<'a, S: std::hash::BuildHasher> Generator<'a, S> {
         if name == "format" {
             self._visit_format_filter(buf, args);
             return DisplayWrap::Unwrapped;
+        } else if name == "fmt" {
+            self._visit_fmt_filter(buf, args);
+            return DisplayWrap::Unwrapped;
         } else if name == "join" {
             self._visit_join_filter(buf, args);
             return DisplayWrap::Unwrapped;
@@ -1038,6 +1041,21 @@ impl<'a, S: std::hash::BuildHasher> Generator<'a, S> {
         buf.write(")");
     }
 
+    fn _visit_fmt_filter(&mut self, buf: &mut Buffer, args: &[Expr]) {
+        buf.write("format!(");
+        if let Some(Expr::StrLit(v)) = args.get(1) {
+            self.visit_str_lit(buf, v);
+            buf.write(", ");
+        } else {
+            panic!("invalid expression type for fmt filter");
+        }
+        self._visit_args(buf, &args[0..1]);
+        if args.len() > 2 {
+            panic!("only two arguments allowed to fmt filter");
+        }
+        buf.write(")");
+    }
+
     // Force type coercion on first argument to `join` filter (see #39).
     fn _visit_join_filter(&mut self, buf: &mut Buffer, args: &[Expr]) {
         buf.write("::askama::filters::join((&");


Test patch:
diff --git a/testing/tests/filters.rs b/testing/tests/filters.rs
index b02a64b..035e5da 100644
--- a/testing/tests/filters.rs
+++ b/testing/tests/filters.rs
@@ -38,6 +38,18 @@ fn filter_format() {
     assert_eq!(t.render().unwrap(), "\"formatted\"");
 }
 
+#[derive(Template)]
+#[template(source = "{{ var|fmt(\"{:?}\") }}", ext = "html", escape = "none")]
+struct FmtTemplate<'a> {
+    var: &'a str,
+}
+
+#[test]
+fn filter_fmt() {
+    let t = FmtTemplate { var: "formatted" };
+    assert_eq!(t.render().unwrap(), "\"formatted\"");
+}
+
 #[derive(Template)]
 #[template(source = "{{ s|myfilter }}", ext = "txt")]
 struct MyFilterTemplate<'a> {