Repository: djc/askama
Pull request #908 (2023-11-17 09:29:21)

Add better support for rust-like number literals
Added support for underscore separators, scientific notation and suffixes (e.g. `usize` or `f64`) for numbers.
Alleviates issue #882 for number literals.


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/lib.rs b/askama_parser/src/lib.rs
index ff26483..4d27d70 100644
--- a/askama_parser/src/lib.rs
+++ b/askama_parser/src/lib.rs
@@ -7,11 +7,10 @@ use std::{fmt, str};
 
 use nom::branch::alt;
 use nom::bytes::complete::{escaped, is_not, tag, take_till};
-use nom::character::complete::char;
-use nom::character::complete::{anychar, digit1};
+use nom::character::complete::{anychar, char, one_of, satisfy};
 use nom::combinator::{cut, eof, map, opt, recognize};
 use nom::error::{Error, ErrorKind, FromExternalError};
-use nom::multi::many1;
+use nom::multi::{many0_count, many1};
 use nom::sequence::{delimited, pair, preceded, terminated, tuple};
 use nom::{error_position, AsChar, InputTakeAtPosition};
 
@@ -250,13 +249,74 @@ fn bool_lit(i: &str) -> ParseResult<'_> {
 }
 
 fn num_lit(i: &str) -> ParseResult<'_> {
+    let integer_suffix = |i| {
+        alt((
+            tag("i8"),
+            tag("i16"),
+            tag("i32"),
+            tag("i64"),
+            tag("i128"),
+            tag("isize"),
+            tag("u8"),
+            tag("u16"),
+            tag("u32"),
+            tag("u64"),
+            tag("u128"),
+            tag("usize"),
+        ))(i)
+    };
+    let float_suffix = |i| alt((tag("f32"), tag("f64")))(i);
+
     recognize(tuple((
         opt(char('-')),
-        digit1,
-        opt(pair(char('.'), digit1)),
+        alt((
+            recognize(tuple((
+                char('0'),
+                alt((
+                    recognize(tuple((char('b'), separated_digits(2, false)))),
+                    recognize(tuple((char('o'), separated_digits(8, false)))),
+                    recognize(tuple((char('x'), separated_digits(16, false)))),
+                )),
+                opt(integer_suffix),
+            ))),
+            recognize(tuple((
+                separated_digits(10, true),
+                opt(alt((
+                    integer_suffix,
+                    float_suffix,
+                    recognize(tuple((
+                        opt(tuple((char('.'), separated_digits(10, true)))),
+                        one_of("eE"),
+                        opt(one_of("+-")),
+                        separated_digits(10, false),
+                        opt(float_suffix),
+                    ))),
+                    recognize(tuple((
+                        char('.'),
+                        separated_digits(10, true),
+                        opt(float_suffix),
+                    ))),
+                ))),
+            ))),
+        )),
     )))(i)
 }
 
+/// Underscore separated digits of the given base, unless `start` is true this may start
+/// with an underscore.
+fn separated_digits(radix: u32, start: bool) -> impl Fn(&str) -> ParseResult<'_> {
+    move |i| {
+        recognize(tuple((
+            |i| match start {
+                true => Ok((i, 0)),
+                false => many0_count(char('_'))(i),
+            },
+            satisfy(|ch| ch.is_digit(radix)),
+            many0_count(satisfy(|ch| ch == '_' || ch.is_digit(radix))),
+        )))(i)
+    }
+}
+
 fn str_lit(i: &str) -> ParseResult<'_> {
     let (i, s) = delimited(
         char('"'),
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/templates/num-literals.html b/testing/templates/num-literals.html
new file mode 100644
index 0000000..90b07db
--- /dev/null
+++ b/testing/templates/num-literals.html
@@ -0,0 +1,11 @@
+{% let plain_int = 9__0__ -%}
+{% let neg_int = -9__0__isize -%}
+{% let suffix_int = 9__0__i32 -%}
+{% let bin_int = 0b__1__0__ -%}
+{% let oct_int = 0o__7__0__ -%}
+{% let hex_int = 0x__f__0__ -%}
+{% let plain_float = 1__0__.5__0__ -%}
+{% let suffix_float = 1__0__.5__0__f32 -%}
+{% let exp_float = 1__0__e+__1__0__f32 -%}
+{% let dotexp_float = 1__0__.5__0__e+__1__0__f32 -%}
+[{{ plain_int }}, {{ neg_int }}, {{ suffix_int }}, {{ bin_int }}, {{ oct_int }}, {{ hex_int }}, {{ plain_float }}, {{ suffix_float }}, {{ exp_float }}, {{ dotexp_float }}]
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/simple.rs b/testing/tests/simple.rs
index 627a8a3..f0f0a26 100644
--- a/testing/tests/simple.rs
+++ b/testing/tests/simple.rs
@@ -461,3 +461,26 @@ fn test_define_string_var() {
     let template = DefineStringVar;
     assert_eq!(template.render().unwrap(), "");
 }
+
+#[derive(askama::Template)]
+#[template(source = "{% let x = 4.5 %}{{ x }}", ext = "html")]
+struct SimpleFloat;
+
+#[test]
+fn test_simple_float() {
+    let template = SimpleFloat;
+    assert_eq!(template.render().unwrap(), "4.5");
+}
+
+#[derive(askama::Template)]
+#[template(path = "num-literals.html")]
+struct NumLiterals;
+
+#[test]
+fn test_num_literals() {
+    let template = NumLiterals;
+    assert_eq!(
+        template.render().unwrap(),
+        "[90, -90, 90, 2, 56, 240, 10.5, 10.5, 100000000000, 105000000000]",
+    );
+}
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)