Repository: djc/askama (rust)

Issue:
Feature request: enhance match to include multiple targets
This has previously been discussed at https://github.com/djc/askama/issues/711#issuecomment-1794340598 but I would like to move it to its own issue as it can be implemented separately.

The request is to enhance Askana's syntax for the `match` block (see https://djc.github.io/askama/template_syntax.html#match) to handle multiple targets in the same 'arm'.

See https://doc.rust-lang.org/reference/expressions/match-expr.html for the Rust syntax in the Reference Manual. Note that a match arm can have multiple targets; the object of this feature request is to replicate this in Askama. (I am not requesting 'match arm guards' (`if` clauses)).

So given code like
```
enum Suit {Clubs, Diamonds, Hearts, Spades}
```
one could write a template like 
```
The card is
{% match suit %}
   {% when Suit::Clubs or Suit::Spades %}
     black
   {% when Suit::Diamonds or Suit::Hearts %}
     red
{% endmatch %}
```
Alternatively, `or` could be replaced by `|` in the proposed design, but I think Askama prefers keywords to symbols.

Looking at the code, `combinator/mod.rs(587)`*ff* defines how match statements are stored in the parse structure and how the code is parsed into this structure, and line 232*ff* covers `when` arms. So the `target` field would need to be a vector of `Targets`, and the `when()` function would need to be able to parse the modified `when` syntax., and the `parse()` function for `Match` around 595 also would need changing.

The `Match` structure is used in `generator.rs` around 544*ff* to generate the Rust code. This currently calls `visit_target` at 1559*ff* to generate the actual target; this would need to be changed around 576 to a call for each target and to output intervening `|` tokens.

There is some code around `heritage.rs(93)`. I'm not sure what it does, but it looks like it moves the match arms without change, so maybe it does not require any change.

Also, the documentation will need changing, of course. near `template_syntax.md(417)`*ff*.

The test could look something like the following (in `testing/tests/matches.rs)`, with the template above in `testing/templates/match-enum.html`).

```
enum Suit {Clubs, Diamonds, Hearts, Spades}
#[derive(Template)]
#[template(path = "match-enum.html")]
struct MatchEnumTemplate {
    item: Suit,
}

#[test]
fn test_match_enum() {
    let s = MatchEnumTemplate { item: Suit::Clubs};
    assert_eq!(s.render().unwrap(), "\nThe card is black\n");

    let s = MatchEnumTemplate { item: Suit::Hearts};
    assert_eq!(s.render().unwrap(), "\nThe card is red\n");

}
```

Pull request description:
Enhance match to include multiple targets
This adds ability to `or` patterns in any context (e.g. in `when` and `let`).
This is done by adding an `OrChain` variant to the `Target` enum in `askama_parser`,
and simply adding a `|` between or-chained patterns in `askama_derive`.

Closes #904

Patch:
diff --git a/askama_derive/src/generator.rs b/askama_derive/src/generator.rs
index eb293b0..c6c5d10 100644
--- a/askama_derive/src/generator.rs
+++ b/askama_derive/src/generator.rs
@@ -719,11 +719,15 @@ impl<'a> Generator<'a> {
         let mut names = Buffer::new(0);
         let mut values = Buffer::new(0);
         let mut is_first_variable = true;
-        for (i, arg) in def.args.iter().enumerate() {
-            let expr = args.get(i).ok_or_else(|| {
-                CompileError::from(format!("macro {name:?} takes more than {i} arguments"))
-            })?;
-
+        if args.len() != def.args.len() {
+            return Err(CompileError::from(format!(
+                "macro {name:?} expected {} argument{}, found {}",
+                def.args.len(),
+                if def.args.len() != 1 { "s" } else { "" },
+                args.len()
+            )));
+        }
+        for (expr, arg) in std::iter::zip(args, &def.args) {
             match expr {
                 // If `expr` is already a form of variable then
                 // don't reintroduce a new variable. This is
@@ -1575,6 +1579,16 @@ impl<'a> Generator<'a> {
                 }
                 buf.write(name);
             }
+            Target::OrChain(targets) => match targets.first() {
+                None => buf.write("_"),
+                Some(first_target) => {
+                    self.visit_target(buf, initialized, first_level, first_target);
+                    for target in &targets[1..] {
+                        buf.write(" | ");
+                        self.visit_target(buf, initialized, first_level, target);
+                    }
+                }
+            },
             Target::Tuple(path, targets) => {
                 buf.write(&path.join("::"));
                 buf.write("(");
diff --git a/askama_parser/src/node.rs b/askama_parser/src/node.rs
index 4da1742..ba117c5 100644
--- a/askama_parser/src/node.rs
+++ b/askama_parser/src/node.rs
@@ -128,10 +128,23 @@ pub enum Target<'a> {
     CharLit(&'a str),
     BoolLit(&'a str),
     Path(Vec<&'a str>),
+    OrChain(Vec<Target<'a>>),
 }
 
 impl<'a> Target<'a> {
+    /// Parses multiple targets with `or` separating them
     pub(super) fn parse(i: &'a str) -> ParseResult<'a, Self> {
+        map(
+            separated_list1(ws(tag("or")), Self::parse_one),
+            |mut opts| match opts.len() {
+                1 => opts.pop().unwrap(),
+                _ => Self::OrChain(opts),
+            },
+        )(i)
+    }
+
+    /// Parses a single target without an `or`, unless it is wrapped in parentheses.
+    fn parse_one(i: &'a str) -> ParseResult<'a, Self> {
         let mut opt_opening_paren = map(opt(ws(char('('))), |o| o.is_some());
         let mut opt_closing_paren = map(opt(ws(char(')'))), |o| o.is_some());
         let mut opt_opening_brace = map(opt(ws(char('{'))), |o| o.is_some());


Test patch:
diff --git a/testing/templates/match-enum-or.html b/testing/templates/match-enum-or.html
new file mode 100644
index 0000000..ffd364d
--- /dev/null
+++ b/testing/templates/match-enum-or.html
@@ -0,0 +1,8 @@
+The card is
+{%- match suit %}
+   {%- when Suit::Clubs or Suit::Spades -%}
+     {{ " black" }}
+   {%- when Suit::Diamonds or Suit::Hearts -%}
+     {{ " red" }}
+{%- endmatch %}
+
diff --git a/testing/tests/matches.rs b/testing/tests/matches.rs
index f5ccb95..4507489 100644
--- a/testing/tests/matches.rs
+++ b/testing/tests/matches.rs
@@ -195,3 +195,32 @@ fn test_match_with_comment() {
     let s = MatchWithComment { good: false };
     assert_eq!(s.render().unwrap(), "bad");
 }
+
+enum Suit {
+    Clubs,
+    Diamonds,
+    Hearts,
+    Spades,
+}
+
+#[derive(Template)]
+#[template(path = "match-enum-or.html")]
+struct MatchEnumOrTemplate {
+    suit: Suit,
+}
+
+#[test]
+fn test_match_enum_or() {
+    let template = MatchEnumOrTemplate { suit: Suit::Clubs };
+    assert_eq!(template.render().unwrap(), "The card is black\n");
+    let template = MatchEnumOrTemplate { suit: Suit::Spades };
+    assert_eq!(template.render().unwrap(), "The card is black\n");
+
+    let template = MatchEnumOrTemplate { suit: Suit::Hearts };
+    assert_eq!(template.render().unwrap(), "The card is red\n");
+
+    let template = MatchEnumOrTemplate {
+        suit: Suit::Diamonds,
+    };
+    assert_eq!(template.render().unwrap(), "The card is red\n");
+}
diff --git a/testing/tests/ui/macro.rs b/testing/tests/ui/macro.rs
new file mode 100644
index 0000000..01c64bf
--- /dev/null
+++ b/testing/tests/ui/macro.rs
@@ -0,0 +1,27 @@
+use askama::Template;
+
+#[derive(Template)]
+#[template(source = "{%- macro thrice(param) -%}
+{{ param }}
+{%- endmacro -%}
+
+{%- call thrice(2, 3) -%}", ext = "html")]
+struct InvalidNumberOfArgs;
+
+#[derive(Template)]
+#[template(source = "{%- macro thrice(param, param2) -%}
+{{ param }} {{ param2 }}
+{%- endmacro -%}
+
+{%- call thrice() -%}", ext = "html")]
+struct InvalidNumberOfArgs2;
+
+#[derive(Template)]
+#[template(source = "{%- macro thrice() -%}
+{%- endmacro -%}
+
+{%- call thrice(1, 2) -%}", ext = "html")]
+struct InvalidNumberOfArgs3;
+
+fn main() {
+}
diff --git a/testing/tests/ui/macro.stderr b/testing/tests/ui/macro.stderr
new file mode 100644
index 0000000..5471c96
--- /dev/null
+++ b/testing/tests/ui/macro.stderr
@@ -0,0 +1,23 @@
+error: macro "thrice" expected 1 argument, found 2
+ --> tests/ui/macro.rs:3:10
+  |
+3 | #[derive(Template)]
+  |          ^^^^^^^^
+  |
+  = note: this error originates in the derive macro `Template` (in Nightly builds, run with -Z macro-backtrace for more info)
+
+error: macro "thrice" expected 2 arguments, found 0
+  --> tests/ui/macro.rs:11:10
+   |
+11 | #[derive(Template)]
+   |          ^^^^^^^^
+   |
+   = note: this error originates in the derive macro `Template` (in Nightly builds, run with -Z macro-backtrace for more info)
+
+error: macro "thrice" expected 0 arguments, found 2
+  --> tests/ui/macro.rs:19:10
+   |
+19 | #[derive(Template)]
+   |          ^^^^^^^^
+   |
+   = note: this error originates in the derive macro `Template` (in Nightly builds, run with -Z macro-backtrace for more info)