Repository: djc/askama (rust)

Issue:
escape filter does not escape when escape attribute is set to none
Perhaps this is just a documentation issue but following the recursive macro workaround described in https://github.com/djc/askama/issues/105#issuecomment-483937352 via #308 I annotated my struct as follows:

```rust
#[derive(Debug, Template)]
#[template(path = "topic/node.html", escape = "none")]
pub struct TopicNode {
    pub topic: Topic,
    pub children: TopicTree,
}
```

With a template that looks like this:

```jinja
<li>{{ topic.name }}</li>
{% if !children.is_empty() %}
<li>
    <ul class="tree-list">
        {% for child in children %}
            {{ child.render().unwrap() }}
        {% endfor %}
    </ul>
</li>
{% endif %}
```

I noticed that the name in the first line was no longer being escaped... not unexpected given `escape = "none"` so I tried to manually escape by doing:

```jinja
<li>{{ topic.name|escape }}</li>
```

which still did not escape the text. It seems the `E` argument of the `escape` filter is filled in with the current escaper, which in this case in `none`. I was able to work around this by defining a custom filter as follows:

```rust
pub fn html_escape<T>(v: T) -> askama::Result<MarkupDisplay<Html, T>>
    where
        T: fmt::Display,
{
    Ok(MarkupDisplay::new_unsafe(v, Html))
}
```

Which, works as expected. Anyway, [the docs](https://djc.github.io/askama/filters.html#escape--e) suggest that HTML will always be escaped so I thought it was worth opening an issue. Thanks.

Pull request description:
 Added support for optional escaper for escape filter
Resolves #556

- Added support for optional escaper arg for `escape filter`, e.g. `{{ foo|escape("html") }}` and `{{ foo|e("html") }}`
- Added tests
- Updated book

Additionally, I removed `fn e()`, which is technically a breaking change, so I added a `pub use escape as e;`. Maybe it's _safe_ to assume _nobody_ depends on `askama_shared` directly. Only to `use askama_shared::filters::e;`, but I'll let you decide on that, then I'll remove it if needed.


Patch:
diff --git a/askama_shared/src/filters/mod.rs b/askama_shared/src/filters/mod.rs
index 8406dee..7009f58 100644
--- a/askama_shared/src/filters/mod.rs
+++ b/askama_shared/src/filters/mod.rs
@@ -106,15 +106,6 @@ where
     Ok(MarkupDisplay::new_unsafe(v, e))
 }
 
-/// Alias for the `escape()` filter
-pub fn e<E, T>(e: E, v: T) -> Result<MarkupDisplay<E, T>>
-where
-    E: Escaper,
-    T: fmt::Display,
-{
-    escape(e, v)
-}
-
 #[cfg(feature = "humansize")]
 /// Returns adequate string representation (in KB, ..) of number of bytes
 pub fn filesizeformat<B: FileSize>(b: &B) -> Result<String> {
diff --git a/askama_shared/src/generator.rs b/askama_shared/src/generator.rs
index 5c72934..78ada4b 100644
--- a/askama_shared/src/generator.rs
+++ b/askama_shared/src/generator.rs
@@ -1091,7 +1091,10 @@ impl<'a, S: std::hash::BuildHasher> Generator<'a, S> {
         mut name: &str,
         args: &[Expr<'_>],
     ) -> Result<DisplayWrap, CompileError> {
-        if name == "format" {
+        if matches!(name, "escape" | "e") {
+            self._visit_escape_filter(buf, args)?;
+            return Ok(DisplayWrap::Wrapped);
+        } else if name == "format" {
             self._visit_format_filter(buf, args)?;
             return Ok(DisplayWrap::Unwrapped);
         } else if name == "fmt" {
@@ -1115,7 +1118,7 @@ impl<'a, S: std::hash::BuildHasher> Generator<'a, S> {
             return Err("the `yaml` filter requires the `serde-yaml` feature to be enabled".into());
         }
 
-        const FILTERS: [&str; 5] = ["safe", "escape", "e", "json", "yaml"];
+        const FILTERS: [&str; 3] = ["safe", "json", "yaml"];
         if FILTERS.contains(&name) {
             buf.write(&format!(
                 "::askama::filters::{}({}, ",
@@ -1135,6 +1138,37 @@ impl<'a, S: std::hash::BuildHasher> Generator<'a, S> {
         })
     }
 
+    fn _visit_escape_filter(
+        &mut self,
+        buf: &mut Buffer,
+        args: &[Expr<'_>],
+    ) -> Result<(), CompileError> {
+        if args.len() > 2 {
+            return Err("only two arguments allowed to escape filter".into());
+        }
+        let opt_escaper = match args.get(1) {
+            Some(Expr::StrLit(name)) => Some(*name),
+            Some(_) => return Err("invalid escaper type for escape filter".into()),
+            None => None,
+        };
+        let escaper = match opt_escaper {
+            Some(name) => self
+                .input
+                .config
+                .escapers
+                .iter()
+                .find_map(|(escapers, escaper)| escapers.contains(name).then(|| escaper))
+                .ok_or(CompileError::Static("invalid escaper for escape filter"))?,
+            None => self.input.escaper,
+        };
+        buf.write("::askama::filters::escape(");
+        buf.write(escaper);
+        buf.write(", ");
+        self._visit_args(buf, &args[..1])?;
+        buf.write(")?");
+        Ok(())
+    }
+
     fn _visit_format_filter(
         &mut self,
         buf: &mut Buffer,
diff --git a/book/src/filters.md b/book/src/filters.md
index bbeb5b8..d00d778 100644
--- a/book/src/filters.md
+++ b/book/src/filters.md
@@ -73,6 +73,28 @@ Output:
 Escape &lt;&gt;&amp;
 ```
 
+Optionally, it is possible to specify and override which escaper is used. Consider a template where the escaper is configured as [`escape = "none"`]. However, somewhere escaping using the HTML escaper is desired. Then it is possible to override and use the HTML escaper like this:
+
+```jinja
+{{ "Don't Escape <>&"|escape }}
+{{ "Don't Escape <>&"|e }}
+
+{{ "Escape <>&"|escape("html") }}
+{{ "Escape <>&"|e("html") }}
+```
+
+Output:
+
+```text
+Don't Escape <>&
+Don't Escape <>&
+
+Escape &lt;&gt;&amp;
+Escape &lt;&gt;&amp;
+```
+
+[`escape = "none"`]: creating_templates.html#the-template-attribute
+
 ### filesizeformat
 
 Returns adequate string representation (in KB, ..) of number of bytes:


Test patch:
diff --git a/testing/tests/filters.rs b/testing/tests/filters.rs
index 1f382f6..be3e0ab 100644
--- a/testing/tests/filters.rs
+++ b/testing/tests/filters.rs
@@ -26,6 +26,54 @@ fn filter_escape() {
     );
 }
 
+#[derive(Template)]
+#[template(
+    source = "{{ \"<h1 class=\\\"title\\\">Foo Bar</h1>\"|escape(\"none\") }}
+{{ \"<h1 class=\\\"title\\\">Foo Bar</h1>\"|escape(\"html\") }}
+{{ \"<h1 class=\\\"title\\\">Foo Bar</h1>\"|escape }}
+{{ \"<h1 class=\\\"title\\\">Foo Bar</h1>\" }}
+",
+    ext = "txt",
+    escape = "none"
+)]
+struct OptEscaperNoneTemplate;
+
+#[test]
+fn filter_opt_escaper_none() {
+    let t = OptEscaperNoneTemplate;
+    assert_eq!(
+        t.render().unwrap(),
+        r#"<h1 class="title">Foo Bar</h1>
+&lt;h1 class=&quot;title&quot;&gt;Foo Bar&lt;/h1&gt;
+<h1 class="title">Foo Bar</h1>
+<h1 class="title">Foo Bar</h1>"#
+    );
+}
+
+#[derive(Template)]
+#[template(
+    source = "{{ \"<h1 class=\\\"title\\\">Foo Bar</h1>\"|escape(\"none\") }}
+{{ \"<h1 class=\\\"title\\\">Foo Bar</h1>\"|escape(\"html\") }}
+{{ \"<h1 class=\\\"title\\\">Foo Bar</h1>\"|escape }}
+{{ \"<h1 class=\\\"title\\\">Foo Bar</h1>\" }}
+",
+    ext = "txt",
+    escape = "html"
+)]
+struct OptEscaperHtmlTemplate;
+
+#[test]
+fn filter_opt_escaper_html() {
+    let t = OptEscaperHtmlTemplate;
+    assert_eq!(
+        t.render().unwrap(),
+        r#"<h1 class="title">Foo Bar</h1>
+&lt;h1 class=&quot;title&quot;&gt;Foo Bar&lt;/h1&gt;
+&lt;h1 class=&quot;title&quot;&gt;Foo Bar&lt;/h1&gt;
+&lt;h1 class=&quot;title&quot;&gt;Foo Bar&lt;/h1&gt;"#
+    );
+}
+
 #[derive(Template)]
 #[template(path = "format.html", escape = "none")]
 struct FormatTemplate<'a> {