Repository: mitsuhiko/minijinja (language: rust)

Issue:
Support for formatting dates
Hello, thanks for writing+maintaining Minijinja; it's really great!

I often need to format dates in my templates. For examples, say I have a `chrono::NaiveDate` in Rust and I want to display it like "May 1, 1980" in the template. [As far as I'm aware, there is currently no great way to do this in Minijinja](https://github.com/mitsuhiko/minijinja/discussions/290) (hopefully I'm wrong).

[Tera has a `date` filter for this](https://tera.netlify.app/docs/#date), behind [a feature](https://github.com/Keats/tera/blob/master/Cargo.toml#L52) so that users can opt out of the `chrono` dependency. It's pretty useful.

Would it make sense to add similar date formatting functionality to Minijinja?


Discussion / hints:
I already wrote such a filter a few times and was close to adding it to minijinja-contrib. I’m in favor of adding it.

I think one might want to generate that filter from a factory function in order to give it a default format string. This type of filter also runs into the issue of having to support the different serialization formats or chrono and time and it also brings up the topic of i18n/l10n.

The sum of those issues was why I didn’t dare to go down that path yet. 

Another option could be to store the default config just in the template context. Eg reserve `LOCALE` and `TIMEZONE` etc. 
I think something like the following might work. A new filter `datetimeformat` is added. It takes a `&State` and `Value`. The `Value` is converted from any of the formats that `chrono` or `time` serialize to (ISO string, ordinal tuples etc.).

For formatting it can take a format string (format TBD) and if it's missing, it's picked from the context from the global `DATETIME_DEFAULT_FORMAT` variable. Likewise the timezone is picked up from `TIMEZONE` and the locale from `LOCALE` (if support for that is added).

For formatting it's unclear what it can use to format. The obvious choices i think are `icu_datetime` or `chrono` with `unstable-locales` (seems unmaintained sadly). The `time` crate also has some formatting capabilities these days but it does not seem to support locales.

Fix patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4b854a9a..1bdf8280 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,8 @@ All notable changes to MiniJinja are documented here.
 ## 1.0.2
 
 - Added `TryFrom` and `ArgType` for `Arc<str>`.
+- Added `datetimeformat`, `dateformat`, `timeformat` and `now()` to the
+  contrib crate.  (#309)
 
 ## 1.0.1
 
diff --git a/minijinja-contrib/Cargo.toml b/minijinja-contrib/Cargo.toml
index e8aa315a..9ce58171 100644
--- a/minijinja-contrib/Cargo.toml
+++ b/minijinja-contrib/Cargo.toml
@@ -14,9 +14,17 @@ rust-version = "1.61"
 [package.metadata.docs.rs]
 rustdoc-args = ["--cfg", "docsrs", "--html-in-header", "doc-header.html"]
 
+[features]
+default = []
+datetime = ["time"]
+timezone = ["time-tz"]
+
 [dependencies]
 minijinja = { version = "1.0.1", path = "../minijinja", default-features = false }
+serde = "1.0.164"
+time = { version = "0.3.22", optional = true, features = ["serde", "formatting"] }
+time-tz = { version = "1.0.3", features = ["db"], optional = true }
 
 [dev-dependencies]
 minijinja = { version = "1.0.1", path = "../minijinja", features = ["loader"] }
-similar-asserts = "1.4.2"
\ No newline at end of file
+similar-asserts = "1.4.2"
diff --git a/minijinja-contrib/src/filters/datetime.rs b/minijinja-contrib/src/filters/datetime.rs
new file mode 100644
index 00000000..dd006689
--- /dev/null
+++ b/minijinja-contrib/src/filters/datetime.rs
@@ -0,0 +1,283 @@
+use std::convert::TryFrom;
+
+use minijinja::value::{Kwargs, Value, ValueKind};
+use minijinja::{Error, ErrorKind, State};
+use serde::de::value::SeqDeserializer;
+use serde::de::IntoDeserializer;
+use serde::Deserialize;
+use time::{format_description, OffsetDateTime};
+use time_tz::OffsetDateTimeExt;
+
+fn handle_serde_error(err: serde::de::value::Error) -> Error {
+    Error::new(ErrorKind::InvalidOperation, "not a valid date").with_source(err)
+}
+
+fn value_to_datetime(value: Value) -> Result<OffsetDateTime, Error> {
+    if let Some(s) = value.as_str() {
+        Ok(OffsetDateTime::deserialize(s.into_deserializer()).map_err(handle_serde_error)?)
+    } else if let Ok(v) = f64::try_from(value.clone()) {
+        OffsetDateTime::from_unix_timestamp_nanos((v * 1e9) as i128)
+            .map_err(|_| Error::new(ErrorKind::InvalidOperation, "date out of range"))
+    } else if value.kind() == ValueKind::Seq {
+        let mut items = Vec::new();
+        for item in value.try_iter()? {
+            items.push(i64::try_from(item)?);
+        }
+        Ok(
+            OffsetDateTime::deserialize(SeqDeserializer::new(items.into_iter()))
+                .map_err(handle_serde_error)?,
+        )
+    } else {
+        Err(Error::new(
+            ErrorKind::InvalidOperation,
+            "value is not a datetime",
+        ))
+    }
+}
+
+/// Formats a timestamp as date and time.
+///
+/// The value needs to be a unix timestamp, or a parsable string (via the [`time`] crate).
+/// The filter accepts two keyword arguments (`format` and `tz`) to influence the format
+/// and the timezone.  The default format is `"medium"`.  The defaults for these keyword
+/// arguments are taken from two global variables in the template context: `DATETIME_FORMAT`
+/// and `TIMEZONE`.  If the timezone is set to `"original"` or is not configured, then
+/// the timezone of the value is retained.  Otherwise the timezone is the name of a
+/// timezone [from the database](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones).
+///
+/// ```jinja
+/// {{ value|datetimeformat }}
+/// ```
+///
+/// ```jinja
+/// {{ value|datetimeformat(format="short") }}
+/// ```
+///
+/// ```jinja
+/// {{ value|datetimeformat(format="short", tz="Europe/Vienna") }}
+/// ```
+///
+/// This filter currently uses the `time` crate to format dates and uses the format
+/// string specification of that crate in version 2.  For more information read the
+/// [Format description documentation](https://time-rs.github.io/book/api/format-description.html).
+/// Additionally some special formats are supported:
+///
+/// * `short`: a short date and time format (`2023-06-24 16:37`)
+/// * `medium`: a medium length date and time format (`Jun 24 2023 16:37`)
+/// * `long`: a longer date and time format (`June 24 2023 16:37:22`)
+/// * `full`: a full date and time format (`Saturday, June 24 2023 16:37:22`)
+/// * `unix`: a unix timestamp in seconds only (`1687624642`)
+/// * `iso`: date and time in iso format (`2023-06-24T16:37:22+00:00`)
+///
+/// This filter requires the `datetime` feature, the timezone support requires the `timezone`
+/// feature.
+#[cfg_attr(docsrs, doc(cfg(feature = "datetime")))]
+pub fn datetimeformat(state: &State, value: Value, kwargs: Kwargs) -> Result<String, Error> {
+    #[allow(unused_mut)]
+    let mut datetime = value_to_datetime(value)?;
+    let configured_format = state.lookup("DATETIME_FORMAT");
+
+    #[cfg(feature = "timezone")]
+    {
+        apply_timezone(state, &kwargs, &mut datetime)?;
+    }
+
+    let format = kwargs.get::<Option<&str>>("format")?.unwrap_or_else(|| {
+        configured_format
+            .as_ref()
+            .and_then(|x| x.as_str())
+            .unwrap_or("medium")
+    });
+    kwargs.assert_all_used()?;
+
+    datetime
+        .format(
+            &format_description::parse_borrowed::<2>(match format {
+                "short" => "[year]-[month]-[day] [hour]:[minute]",
+                "medium" => "[month repr:short] [day padding:none] [year] [hour]:[minute]",
+                "long" => "[month repr:long] [day padding:none] [year] [hour]:[minute]:[second]",
+                "full" => "[weekday], [month repr:long] [day padding:none] [year] [hour]:[minute]:[second].[subsecond]",
+                "iso" => {
+                    "[year]-[month]-[day]T[hour]:[minute]:[second]+[offset_hour]:[offset_minute]"
+                }
+                "unix" => "[unix_timestamp]",
+                other => other,
+            })
+            .map_err(|err| {
+                Error::new(ErrorKind::InvalidOperation, "invalid format string").with_source(err)
+            })?,
+        )
+        .map_err(|err| {
+            Error::new(ErrorKind::InvalidOperation, "failed to format date").with_source(err)
+        })
+}
+
+/// Formats a timestamp as time.
+///
+/// The value needs to be a unix timestamp, or a parsable string (via the [`time`] crate).
+/// The filter accepts two keyword arguments (`format` and `tz`) to influence the format
+/// and the timezone.  The default format is `"medium"`.  The defaults for these keyword
+/// arguments are taken from two global variables in the template context: `TIME_FORMAT`
+/// and `TIMEZONE`.  If the timezone is set to `"original"` or is not configured, then
+/// the timezone of the value is retained.  Otherwise the timezone is the name of a
+/// timezone [from the database](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones).
+///
+/// ```jinja
+/// {{ value|timeformat }}
+/// ```
+///
+/// ```jinja
+/// {{ value|timeformat(format="short") }}
+/// ```
+///
+/// ```jinja
+/// {{ value|timeformat(format="short", tz="Europe/Vienna") }}
+/// ```
+///
+/// This filter currently uses the `time` crate to format dates and uses the format
+/// string specification of that crate in version 2.  For more information read the
+/// [Format description documentation](https://time-rs.github.io/book/api/format-description.html).
+/// Additionally some special formats are supported:
+///
+/// * `short` and `medium`: hour and minute (`16:37`)
+/// * `long`: includes seconds too (`16:37:22`)
+/// * `full`: includes subseconds too (`16:37:22.0`)
+/// * `unix`: a unix timestamp in seconds only (`1687624642`)
+/// * `iso`: date and time in iso format (`2023-06-24T16:37:22+00:00`)
+///
+/// This filter requires the `datetime` feature, the timezone support requires the `timezone`
+/// feature.
+#[cfg_attr(docsrs, doc(cfg(feature = "datetime")))]
+pub fn timeformat(state: &State, value: Value, kwargs: Kwargs) -> Result<String, Error> {
+    #[allow(unused_mut)]
+    let mut datetime = value_to_datetime(value)?;
+    let configured_format = state.lookup("TIME_FORMAT");
+
+    #[cfg(feature = "timezone")]
+    {
+        apply_timezone(state, &kwargs, &mut datetime)?;
+    }
+
+    let format = kwargs.get::<Option<&str>>("format")?.unwrap_or_else(|| {
+        configured_format
+            .as_ref()
+            .and_then(|x| x.as_str())
+            .unwrap_or("medium")
+    });
+    kwargs.assert_all_used()?;
+
+    datetime
+        .format(
+            &format_description::parse_borrowed::<2>(match format {
+                "short" | "medium" => "[hour]:[minute]",
+                "long" => "[hour]:[minute]:[second]",
+                "full" => "[hour]:[minute]:[second].[subsecond]",
+                "iso" => {
+                    "[year]-[month]-[day]T[hour]:[minute]:[second]+[offset_hour]:[offset_minute]"
+                }
+                "unix" => "[unix_timestamp]",
+                other => other,
+            })
+            .map_err(|err| {
+                Error::new(ErrorKind::InvalidOperation, "invalid format string").with_source(err)
+            })?,
+        )
+        .map_err(|err| {
+            Error::new(ErrorKind::InvalidOperation, "failed to format date").with_source(err)
+        })
+}
+
+/// Formats a timestamp as date.
+///
+/// The value needs to be a unix timestamp, or a parsable string (via the [`time`] crate).
+/// The filter accepts two keyword arguments (`format` and `tz`) to influence the format
+/// and the timezone.  The default format is `"medium"`.  The defaults for these keyword
+/// arguments are taken from two global variables in the template context: `DATE_FORMAT`
+/// and `TIMEZONE`.  If the timezone is set to `"original"` or is not configured, then
+/// the timezone of the value is retained.  Otherwise the timezone is the name of a
+/// timezone [from the database](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones).
+///
+/// ```jinja
+/// {{ value|dateformat }}
+/// ```
+///
+/// ```jinja
+/// {{ value|dateformat(format="short") }}
+/// ```
+///
+/// ```jinja
+/// {{ value|dateformat(format="short", tz="Europe/Vienna") }}
+/// ```
+///
+/// This filter currently uses the `time` crate to format dates and uses the format
+/// string specification of that crate in version 2.  For more information read the
+/// [Format description documentation](https://time-rs.github.io/book/api/format-description.html).
+/// Additionally some special formats are supported:
+///
+/// * `short`: a short date format (`2023-06-24`)
+/// * `medium`: a medium length date format (`Jun 24 2023`)
+/// * `long`: a longer date format (`June 24 2023`)
+/// * `full`: a full date format (`Saturday, June 24 2023`)
+///
+/// This filter requires the `datetime` feature, the timezone support requires the `timezone`
+/// feature.
+#[cfg_attr(docsrs, doc(cfg(feature = "datetime")))]
+pub fn dateformat(state: &State, value: Value, kwargs: Kwargs) -> Result<String, Error> {
+    #[allow(unused_mut)]
+    let mut datetime = value_to_datetime(value)?;
+    let configured_format = state.lookup("DATE_FORMAT");
+
+    #[cfg(feature = "timezone")]
+    {
+        apply_timezone(state, &kwargs, &mut datetime)?;
+    }
+
+    let format = kwargs.get::<Option<&str>>("format")?.unwrap_or_else(|| {
+        configured_format
+            .as_ref()
+            .and_then(|x| x.as_str())
+            .unwrap_or("medium")
+    });
+    kwargs.assert_all_used()?;
+
+    datetime
+        .format(
+            &format_description::parse_borrowed::<2>(match format {
+                "short" => "[year]-[month]-[day]",
+                "medium" => "[month repr:short] [day padding:none] [year]",
+                "long" => "[month repr:long] [day padding:none] [year]",
+                "full" => "[weekday], [month repr:long] [day padding:none] [year]",
+                other => other,
+            })
+            .map_err(|err| {
+                Error::new(ErrorKind::InvalidOperation, "invalid format string").with_source(err)
+            })?,
+        )
+        .map_err(|err| {
+            Error::new(ErrorKind::InvalidOperation, "failed to format date").with_source(err)
+        })
+}
+
+fn apply_timezone(
+    state: &State,
+    kwargs: &Kwargs,
+    datetime: &mut OffsetDateTime,
+) -> Result<(), Error> {
+    let configured_tz = state.lookup("TIMEZONE");
+    let tzname = kwargs.get::<Option<&str>>("tz")?.unwrap_or_else(|| {
+        configured_tz
+            .as_ref()
+            .and_then(|x| x.as_str())
+            .unwrap_or("original")
+    });
+    if tzname != "original" {
+        let tz = time_tz::timezones::get_by_name(tzname).ok_or_else(|| {
+            Error::new(
+                ErrorKind::InvalidOperation,
+                format!("unknown timezone '{}'", tzname),
+            )
+        })?;
+        *datetime = datetime.to_timezone(tz)
+    };
+    Ok(())
+}
diff --git a/minijinja-contrib/src/filters.rs b/minijinja-contrib/src/filters/mod.rs
similarity index 94%
rename from minijinja-contrib/src/filters.rs
rename to minijinja-contrib/src/filters/mod.rs
index 4a96d54b..12348235 100644
--- a/minijinja-contrib/src/filters.rs
+++ b/minijinja-contrib/src/filters/mod.rs
@@ -3,6 +3,12 @@ use std::convert::TryFrom;
 use minijinja::value::Value;
 use minijinja::{Error, ErrorKind};
 
+#[cfg(feature = "datetime")]
+mod datetime;
+
+#[cfg(feature = "datetime")]
+pub use self::datetime::*;
+
 /// Returns a plural suffix if the value is not 1, '1', or an object of
 /// length 1.
 ///
diff --git a/minijinja-contrib/src/globals.rs b/minijinja-contrib/src/globals.rs
new file mode 100644
index 00000000..54d5c271
--- /dev/null
+++ b/minijinja-contrib/src/globals.rs
@@ -0,0 +1,12 @@
+#[allow(unused)]
+use minijinja::value::Value;
+
+/// Returns the current time in UTC as unix timestamp.
+///
+/// To format this timestamp, use the [`datetimeformat`](crate::filters::datetimeformat) filter.
+#[cfg(feature = "datetime")]
+#[cfg_attr(docsrs, doc(cfg(feature = "datetime")))]
+pub fn now() -> Value {
+    let now = time::OffsetDateTime::now_utc();
+    Value::from(((now.unix_timestamp_nanos() / 1000) as f64) / 1_000_000.0)
+}
diff --git a/minijinja-contrib/src/lib.rs b/minijinja-contrib/src/lib.rs
index 21678c8a..920bc531 100644
--- a/minijinja-contrib/src/lib.rs
+++ b/minijinja-contrib/src/lib.rs
@@ -1,6 +1,35 @@
 //! MiniJinja-Contrib is a utility crate for [MiniJinja](https://github.com/mitsuhiko/minijinja)
 //! that adds support for certain utilities that are too specific for the MiniJinja core.  This is
 //! usually because they provide functionality that Jinja2 itself does not have.
+//!
+//! To add all of these to an environment you can use the [`add_to_environment`] function.
+//!
+//! ```
+//! use minijinja::Environment;
+//!
+//! let mut env = Environment::new();
+//! minijinja_contrib::add_to_environment(&mut env);
+//! ```
+
+use minijinja::Environment;
 
 /// Utility filters.
 pub mod filters;
+
+/// Globals
+pub mod globals;
+
+/// Registers all features of this crate with an [`Environment`].
+///
+/// All the filters that are available will be added, same with global
+/// functions that exist.
+pub fn add_to_environment(env: &mut Environment) {
+    env.add_filter("pluralize", filters::pluralize);
+    #[cfg(feature = "datetime")]
+    {
+        env.add_filter("datetimeformat", filters::datetimeformat);
+        env.add_filter("timeformat", filters::timeformat);
+        env.add_filter("dateformat", filters::dateformat);
+        env.add_function("now", globals::now);
+    }
+}
diff --git a/minijinja/src/filters.rs b/minijinja/src/filters.rs
index 0cfd3a2a..bb2f586d 100644
--- a/minijinja/src/filters.rs
+++ b/minijinja/src/filters.rs
@@ -72,6 +72,9 @@
 //! this module.  Note though that these functions are not to be
 //! called from Rust code as their exact interface (arguments and return types)
 //! might change from one MiniJinja version to another.
+//!
+//! Some additional filters are available in the
+//! [`minijinja-contrib`](https://crates.io/crates/minijinja-contrib) crate.
 use std::sync::Arc;
 
 use crate::error::Error;


Test patch:
diff --git a/minijinja-contrib/tests/datetime.rs b/minijinja-contrib/tests/datetime.rs
new file mode 100644
index 00000000..b8e171f4
--- /dev/null
+++ b/minijinja-contrib/tests/datetime.rs
@@ -0,0 +1,121 @@
+#![cfg(all(feature = "datetime", feature = "timezone"))]
+use minijinja::context;
+use similar_asserts::assert_eq;
+
+#[test]
+fn test_datetimeformat() {
+    let mut env = minijinja::Environment::new();
+    env.add_global("TIMEZONE", "Europe/Vienna");
+    env.add_global("DATETIME_FORMAT", "[hour]:[minute]");
+    minijinja_contrib::add_to_environment(&mut env);
+
+    let expr = env
+        .compile_expression("1687624642.5|datetimeformat(format=format)")
+        .unwrap();
+
+    assert_eq!(
+        expr.eval(context!(format => "short")).unwrap().to_string(),
+        "2023-06-24 18:37"
+    );
+    assert_eq!(
+        expr.eval(context!(format => "medium")).unwrap().to_string(),
+        "Jun 24 2023 18:37"
+    );
+    assert_eq!(
+        expr.eval(context!(format => "long")).unwrap().to_string(),
+        "June 24 2023 18:37:22"
+    );
+    assert_eq!(
+        expr.eval(context!(format => "full")).unwrap().to_string(),
+        "Saturday, June 24 2023 18:37:22.5"
+    );
+    assert_eq!(
+        expr.eval(context!(format => "unix")).unwrap().to_string(),
+        "1687624642"
+    );
+    assert_eq!(
+        expr.eval(context!(format => "iso")).unwrap().to_string(),
+        "2023-06-24T18:37:22+02:00"
+    );
+
+    let expr = env
+        .compile_expression("1687624642|datetimeformat(tz='Europe/Moscow')")
+        .unwrap();
+    assert_eq!(expr.eval(()).unwrap().to_string(), "19:37");
+}
+
+#[test]
+fn test_dateformat() {
+    let mut env = minijinja::Environment::new();
+    env.add_global("TIMEZONE", "Europe/Vienna");
+    env.add_global("DATE_FORMAT", "[year]-[month]");
+    minijinja_contrib::add_to_environment(&mut env);
+
+    let expr = env
+        .compile_expression("1687624642.5|dateformat(format=format)")
+        .unwrap();
+
+    assert_eq!(
+        expr.eval(context!(format => "short")).unwrap().to_string(),
+        "2023-06-24"
+    );
+    assert_eq!(
+        expr.eval(context!(format => "medium")).unwrap().to_string(),
+        "Jun 24 2023"
+    );
+    assert_eq!(
+        expr.eval(context!(format => "long")).unwrap().to_string(),
+        "June 24 2023"
+    );
+    assert_eq!(
+        expr.eval(context!(format => "full")).unwrap().to_string(),
+        "Saturday, June 24 2023"
+    );
+
+    let expr = env
+        .compile_expression("1687624642|dateformat(tz='Europe/Moscow')")
+        .unwrap();
+    assert_eq!(expr.eval(()).unwrap().to_string(), "2023-06");
+}
+
+#[test]
+fn test_timeformat() {
+    let mut env = minijinja::Environment::new();
+    env.add_global("TIMEZONE", "Europe/Vienna");
+    env.add_global("TIME_FORMAT", "[hour]:[minute]");
+    minijinja_contrib::add_to_environment(&mut env);
+
+    let expr = env
+        .compile_expression("1687624642.5|timeformat(format=format)")
+        .unwrap();
+
+    assert_eq!(
+        expr.eval(context!(format => "short")).unwrap().to_string(),
+        "18:37"
+    );
+    assert_eq!(
+        expr.eval(context!(format => "medium")).unwrap().to_string(),
+        "18:37"
+    );
+    assert_eq!(
+        expr.eval(context!(format => "long")).unwrap().to_string(),
+        "18:37:22"
+    );
+    assert_eq!(
+        expr.eval(context!(format => "full")).unwrap().to_string(),
+        "18:37:22.5"
+    );
+    assert_eq!(
+        expr.eval(context!(format => "unix")).unwrap().to_string(),
+        "1687624642"
+    );
+    assert_eq!(
+        expr.eval(context!(format => "iso")).unwrap().to_string(),
+        "2023-06-24T18:37:22+02:00"
+    );
+
+    let expr = env
+        .compile_expression("1687624642|timeformat(tz='Europe/Moscow')")
+        .unwrap();
+    assert_eq!(expr.eval(()).unwrap().to_string(), "19:37");
+}
diff --git a/minijinja-contrib/tests/filters.rs b/minijinja-contrib/tests/filters.rs
index a6312c86..ae920621 100644
--- a/minijinja-contrib/tests/filters.rs
+++ b/minijinja-contrib/tests/filters.rs
@@ -1,5 +1,4 @@
 use minijinja_contrib::filters::pluralize;
-#[cfg(test)]
 use similar_asserts::assert_eq;
 
 #[test]