Repository: askama-rs/askama (Rust)

Issue:
Comparison operators cannot be chained
Comparison operators are non-associative in rust, but they are left associative in askama:
<https://github.com/askama-rs/askama/blob/1b2c54620e40bab52afabed1a343a75233cbb672/askama_parser/src/expr.rs#L283-L283>
<https://github.com/askama-rs/askama/blob/1b2c54620e40bab52afabed1a343a75233cbb672/askama_parser/src/expr.rs#L19-L35>

The non-associativity should be reflected in our parser.

----

Fuzzing failure in `derive`.

Artifact:

    data:application/octet-steam;base64,///LDHt7dnU3MjE4LzYzZTM2NjY2NjMtNjY2LzMzMzBlNjMzLzYzZTM2NjY2NjM2NjYvMzMzMzxjIn0BMn0AIjxjN319MiIiIiIAAAAAAACY/2ZmZmbLSww=

Formatted test case:

```rust
use askama_derive::derive_template;
use quote::quote;

#[test]
fn test() -> Result<(), syn::Error> {
    let input = quote! {
        #[template(
            ext = "",
            source = "\u{c}{{vu7218/63e3666663-666/3330e633/63e3666663666/3333<c\"}\u{1}2}\0\"<c7}}2\"\"\"\"\0\0\0\0"
        )]
        enum fff {}
    };
    let output = derive_template(input, import_askama);
    let _: syn::File = syn::parse2(output)?;
    Ok(())
}

fn import_askama() -> TokenStream {
    quote! {
        extern crate askama;
    }
}
```

Generated code:

```rust
__askama_writer.write_str("")?;
match (
    &((&&askama::filters::AutoEscaper::new(
        &(self.vu7218 / 63e3666663 - 666 / 3330e633 / 63e3666663666 / 3333 < c"}2} " < self.c7),
//                                                                        ^^^        ^^^
        askama::filters::Text
    )).askama_auto_escape()?),
) {
    (expr1,) => {
        (&&&askama::filters::Writable(expr1)).askama_write(__askama_writer, __askama_values)?;
    }
}
__askama_writer.write_str("2\"\"\"\"    ")?;
```




Discussion:

Patch:
diff --git a/askama_parser/src/expr.rs b/askama_parser/src/expr.rs
index 8ccb7c8db..a7c99bc23 100644
--- a/askama_parser/src/expr.rs
+++ b/askama_parser/src/expr.rs
@@ -280,7 +280,33 @@ impl<'a> Expr<'a> {
 
     expr_prec_layer!(or, and, "||");
     expr_prec_layer!(and, compare, "&&");
-    expr_prec_layer!(compare, bor, alt(("==", "!=", ">=", ">", "<=", "<",)));
+
+    fn compare(i: &mut &'a str, level: Level<'_>) -> ParseResult<'a, WithSpan<'a, Self>> {
+        let right = |i: &mut _| {
+            let op = alt(("==", "!=", ">=", ">", "<=", "<"));
+            (ws(op), |i: &mut _| Self::bor(i, level)).parse_next(i)
+        };
+
+        let start = *i;
+        let expr = Self::bor(i, level)?;
+        let Some((op, rhs)) = opt(right).parse_next(i)? else {
+            return Ok(expr);
+        };
+        let expr = WithSpan::new(Self::BinOp(op, Box::new(expr), Box::new(rhs)), start);
+
+        if let Some((op2, _)) = opt(right).parse_next(i)? {
+            return Err(winnow::error::ErrMode::Cut(ErrorContext::new(
+                format!(
+                    "comparison operators cannot be chained; \
+                    consider using explicit parentheses, e.g.  `(_ {op} _) {op2} _`"
+                ),
+                op,
+            )));
+        }
+
+        Ok(expr)
+    }
+
     expr_prec_layer!(bor, bxor, "bitor".value("|"));
     expr_prec_layer!(bxor, band, token_xor);
     expr_prec_layer!(band, shifts, token_bitand);


Test patch:
diff --git a/askama_derive/src/tests.rs b/askama_derive/src/tests.rs
index 5f6dcb0f9..3c89ae70a 100644
--- a/askama_derive/src/tests.rs
+++ b/askama_derive/src/tests.rs
@@ -1225,3 +1225,17 @@ fn fuzzed_0b85() -> Result<(), syn::Error> {
     let _: syn::File = syn::parse2(output)?;
     Ok(())
 }
+
+#[test]
+fn fuzzed_comparator_chain() -> Result<(), syn::Error> {
+    let input = quote! {
+        #[template(
+            ext = "",
+            source = "\u{c}{{vu7218/63e3666663-666/3330e633/63e3666663666/3333<c\"}\u{1}2}\0\"<c7}}2\"\"\"\"\0\0\0\0"
+        )]
+        enum fff {}
+    };
+    let output = crate::derive_template(input, import_askama);
+    let _: syn::File = syn::parse2(output)?;
+    Ok(())
+}
diff --git a/askama_parser/src/tests.rs b/askama_parser/src/tests.rs
index c44eb154b..fda99dfef 100644
--- a/askama_parser/src/tests.rs
+++ b/askama_parser/src/tests.rs
@@ -605,35 +605,6 @@ fn test_associativity() {
             ))
         )],
     );
-    assert_eq!(
-        Ast::from_str("{{ a == b != c > d > e == f }}", None, &syntax)
-            .unwrap()
-            .nodes,
-        vec![Node::Expr(
-            Ws(None, None),
-            WithSpan::no_span(Expr::BinOp(
-                "==",
-                Box::new(WithSpan::no_span(Expr::BinOp(
-                    ">",
-                    Box::new(WithSpan::no_span(Expr::BinOp(
-                        ">",
-                        Box::new(WithSpan::no_span(Expr::BinOp(
-                            "!=",
-                            Box::new(WithSpan::no_span(Expr::BinOp(
-                                "==",
-                                Box::new(WithSpan::no_span(Expr::Var("a"))),
-                                Box::new(WithSpan::no_span(Expr::Var("b")))
-                            ))),
-                            Box::new(WithSpan::no_span(Expr::Var("c")))
-                        ))),
-                        Box::new(WithSpan::no_span(Expr::Var("d")))
-                    ))),
-                    Box::new(WithSpan::no_span(Expr::Var("e")))
-                ))),
-                Box::new(WithSpan::no_span(Expr::Var("f")))
-            ))
-        )],
-    );
 }
 
 #[test]
@@ -1423,3 +1394,26 @@ fn there_is_no_digit_two_in_a_binary_integer() {
     assert!(Ast::from_str("{{ 0o9 }}", None, &syntax).is_err());
     assert!(Ast::from_str("{{ 0xg }}", None, &syntax).is_err());
 }
+
+#[test]
+fn comparison_operators_cannot_be_chained() {
+    const OPS: &[&str] = &["==", "!=", ">=", ">", "<=", "<"];
+
+    let syntax = Syntax::default();
+    for op1 in OPS {
+        assert!(Ast::from_str(&format!("{{{{ a {op1} b }}}}"), None, &syntax).is_ok());
+        for op2 in OPS {
+            assert!(Ast::from_str(&format!("{{{{ a {op1} b {op2} c }}}}"), None, &syntax).is_err());
+            for op3 in OPS {
+                assert!(
+                    Ast::from_str(
+                        &format!("{{{{ a {op1} b {op2} c {op3} d }}}}"),
+                        None,
+                        &syntax,
+                    )
+                    .is_err()
+                );
+            }
+        }
+    }
+}
diff --git a/testing/tests/ui/comparator-chaining.rs b/testing/tests/ui/comparator-chaining.rs
new file mode 100644
index 000000000..0edf4aac5
--- /dev/null
+++ b/testing/tests/ui/comparator-chaining.rs
@@ -0,0 +1,49 @@
+// Comparison operators cannot be chained, so our parser must reject chained comparisons.
+
+use askama::Template;
+
+#[derive(Template)]
+#[template(ext = "txt", source = "{{ a == b != c }}")]
+struct EqNe {
+    a: usize,
+    b: usize,
+    c: usize,
+}
+
+#[derive(Template)]
+#[template(ext = "txt", source = "{{ a <= b < c }}")]
+struct Between {
+    a: usize,
+    b: usize,
+    c: usize,
+}
+
+#[derive(Template)]
+#[template(ext = "txt", source = "{{ ((a == b) == c) == d == e }}")]
+struct ThreeTimesOk {
+    a: usize,
+    b: usize,
+    c: usize,
+    d: usize,
+    e: usize,
+}
+
+#[derive(Template)]
+#[template(ext = "txt", source = "{{ a == (b == (c == d == e)) }}")]
+struct ThreeTimesOk2 {
+    a: usize,
+    b: usize,
+    c: usize,
+    d: usize,
+    e: usize,
+}
+
+// Regression test for <https://github.com/askama-rs/askama/issues/454>
+#[derive(Template)]
+#[template(
+    ext = "",
+    source = "\u{c}{{vu7218/63e3666663-666/3330e633/63e3666663666/3333<c\"}\u{1}2}\0\"<c7}}2\"\"\"\"\0\0\0\0"
+)]
+struct Regression {}
+
+fn main() {}
diff --git a/testing/tests/ui/comparator-chaining.stderr b/testing/tests/ui/comparator-chaining.stderr
new file mode 100644
index 000000000..a2a0f48fd
--- /dev/null
+++ b/testing/tests/ui/comparator-chaining.stderr
@@ -0,0 +1,39 @@
+error: comparison operators cannot be chained; consider using explicit parentheses, e.g.  `(_ == _) != _`
+ --> <source attribute>:1:5
+       "== b != c }}"
+ --> tests/ui/comparator-chaining.rs:6:34
+  |
+6 | #[template(ext = "txt", source = "{{ a == b != c }}")]
+  |                                  ^^^^^^^^^^^^^^^^^^^
+
+error: comparison operators cannot be chained; consider using explicit parentheses, e.g.  `(_ <= _) < _`
+ --> <source attribute>:1:5
+       "<= b < c }}"
+  --> tests/ui/comparator-chaining.rs:14:34
+   |
+14 | #[template(ext = "txt", source = "{{ a <= b < c }}")]
+   |                                  ^^^^^^^^^^^^^^^^^^
+
+error: comparison operators cannot be chained; consider using explicit parentheses, e.g.  `(_ == _) == _`
+ --> <source attribute>:1:19
+       "== d == e }}"
+  --> tests/ui/comparator-chaining.rs:22:34
+   |
+22 | #[template(ext = "txt", source = "{{ ((a == b) == c) == d == e }}")]
+   |                                  ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+error: comparison operators cannot be chained; consider using explicit parentheses, e.g.  `(_ == _) == _`
+ --> <source attribute>:1:17
+       "== d == e)) }}"
+  --> tests/ui/comparator-chaining.rs:32:34
+   |
+32 | #[template(ext = "txt", source = "{{ a == (b == (c == d == e)) }}")]
+   |                                  ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+error: comparison operators cannot be chained; consider using explicit parentheses, e.g.  `(_ < _) < _`
+ --> <source attribute>:1:52
+       "<c\"}\u{1}2}\0\"<c7}}2\"\"\"\"\0\0\0\0"
+  --> tests/ui/comparator-chaining.rs:45:14
+   |
+45 |     source = "\u{c}{{vu7218/63e3666663-666/3330e633/63e3666663666/3333<c\"}\u{1}2}\0\"<c7}}2\"\"\"\"\0\0\0\0"
+   |              ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
diff --git a/testing/tests/ui/crate_identifier.rs b/testing/tests/ui/crate_identifier.rs
index a066ae887..69fa9dd2f 100644
--- a/testing/tests/ui/crate_identifier.rs
+++ b/testing/tests/ui/crate_identifier.rs
@@ -96,7 +96,7 @@ struct SmallSelf3 {
 #[derive(Template)]
 #[template(
     ext = "",
-    source = "{{\u{c}KK3e331<c7}}61/63m3333u7<c0.}}\u{6}\0\u{c}\u{c}{{c/crate<338<c7}}6unsafe/63a3ae\u{c}\u{c}\u{c}%et"
+    source = "{{\u{c}KK3e331<c7}}61/63m3333u7<c0.}}\u{6}\0\u{c}\u{c}{{c/crate<338}}6unsafe/63a3ae\u{c}\u{c}\u{c}%et"
 )]
 struct Regression {}
 
diff --git a/testing/tests/ui/crate_identifier.stderr b/testing/tests/ui/crate_identifier.stderr
index 67be08f46..4bb17b999 100644
--- a/testing/tests/ui/crate_identifier.stderr
+++ b/testing/tests/ui/crate_identifier.stderr
@@ -168,11 +168,11 @@ error: `self` cannot be used as an identifier
 
 error: `crate` cannot be used as an identifier
  --> <source attribute>:1:41
-       "crate<338<c7}}6unsafe/63a3ae\u{c}\u{c}\u{c}%et"
+       "crate<338}}6unsafe/63a3ae\u{c}\u{c}\u{c}%et"
   --> tests/ui/crate_identifier.rs:99:14
    |
-99 |     source = "{{\u{c}KK3e331<c7}}61/63m3333u7<c0.}}\u{6}\0\u{c}\u{c}{{c/crate<338<c7}}6unsafe/63a3ae\u{c}\u{c}\u{c}%et"
-   |              ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+99 |     source = "{{\u{c}KK3e331<c7}}61/63m3333u7<c0.}}\u{6}\0\u{c}\u{c}{{c/crate<338}}6unsafe/63a3ae\u{c}\u{c}\u{c}%et"
+   |              ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
 
 error: `super` cannot be used as an identifier
  --> <source attribute>:1:27