Repository: mitsuhiko/minijinja
Pull request #647 (2024-11-18 13:00:16)

Added |truncate filter

This adds the missing |truncate filter.

yacir: ci fixed

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3d66078..05aa2b3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,7 @@ All notable changes to MiniJinja are documented here.
 ## 2.6.0
 
 - Added `sum` filter.  #648
+- Added `truncate` filter to `minijinja-contrib`.  #647
 
 ## 2.5.0
 
diff --git a/minijinja-contrib/src/filters/mod.rs b/minijinja-contrib/src/filters/mod.rs
index 560b778..a303ff2 100644
--- a/minijinja-contrib/src/filters/mod.rs
+++ b/minijinja-contrib/src/filters/mod.rs
@@ -1,6 +1,7 @@
 use std::convert::TryFrom;
 
-use minijinja::value::Value;
+use minijinja::value::{Kwargs, Value, ValueKind};
+use minijinja::State;
 use minijinja::{Error, ErrorKind};
 
 #[cfg(feature = "datetime")]
@@ -121,3 +122,95 @@ pub fn filesizeformat(value: f64, binary: Option<bool>) -> String {
         unreachable!();
     }
 }
+
+/// Returns a truncated copy of the string.
+///
+/// The string will be truncated to the specified length, with an ellipsis
+/// appended if truncation occurs. By default, the filter tries to preserve
+/// whole words.
+///
+/// ```jinja
+/// {{ "Hello World"|truncate(length=5) }}
+/// ```
+///
+/// The filter accepts a few keyword arguments:
+/// * `length`: maximum length of the output string (defaults to 255)
+/// * `killwords`: set to `true` if you want to cut text exactly at length; if `false`,
+///   the filter will preserve last word (defaults to `false`)
+/// * `end`: if you want a specific ellipsis sign you can specify it (defaults to "...")
+/// * `leeway`: determines the tolerance margin before truncation occurs (defaults to 5)
+///
+/// The truncation only occurs if the string length exceeds both the specified
+/// length and the leeway margin combined. This means that if a string is just
+/// slightly longer than the target length (within the leeway value), it will
+/// be left unmodified.
+///
+/// When `killwords` is set to false (default behavior), the function ensures
+/// that words remain intact by finding the last complete word that fits within
+/// the length limit. This prevents words from being cut in the middle and
+/// maintains text readability.
+///
+/// The specified length parameter is inclusive of the end string (ellipsis).
+/// For example, with a length of 5 and the default ellipsis "...", only 2
+/// characters from the original string will be preserved.
+///
+/// # Example with all attributes
+/// ```jinja
+/// {{ "Hello World"|truncate(
+///     length=5,
+///     killwords=true,
+///     end='...',
+///     leeway=2
+/// ) }}
+/// ```
+pub fn truncate(state: &State, value: Value, kwargs: Kwargs) -> Result<String, Error> {
+    if matches!(value.kind(), ValueKind::None | ValueKind::Undefined) {
+        return Ok("".into());
+    }
+
+    let s = value.as_str().ok_or_else(|| {
+        Error::new(
+            ErrorKind::InvalidOperation,
+            format!("expected string, got {}", value.kind()),
+        )
+    })?;
+
+    let length = kwargs.get::<Option<usize>>("length")?.unwrap_or(255);
+    let killwords = kwargs.get::<Option<bool>>("killwords")?.unwrap_or_default();
+    let end = kwargs.get::<Option<&str>>("end")?.unwrap_or("...");
+    let leeway = kwargs.get::<Option<usize>>("leeway")?.unwrap_or_else(|| {
+        state
+            .lookup("TRUNCATE_LEEWAY")
+            .and_then(|x| usize::try_from(x.clone()).ok())
+            .unwrap_or(5)
+    });
+
+    kwargs.assert_all_used()?;
+
+    let end_len = end.chars().count();
+    if length < end_len {
+        return Err(Error::new(
+            ErrorKind::InvalidOperation,
+            format!("expected length >= {}, got {}", end_len, length),
+        ));
+    }
+
+    if s.chars().count() <= length + leeway {
+        return Ok(s.to_string());
+    }
+
+    let trunc_pos = length - end_len;
+    let truncated = if killwords {
+        s.chars().take(trunc_pos).collect::<String>()
+    } else {
+        let chars: Vec<char> = s.chars().take(trunc_pos).collect();
+        match chars.iter().rposition(|&c| c == ' ') {
+            Some(last_space) => chars[..last_space].iter().collect(),
+            None => chars.iter().collect(),
+        }
+    };
+    let mut result = String::with_capacity(truncated.len() + end.len());
+    result.push_str(&truncated);
+    result.push_str(end);
+    Ok(result)
+}
diff --git a/minijinja-contrib/src/lib.rs b/minijinja-contrib/src/lib.rs
index c8b6d67..2f9c6b9 100644
--- a/minijinja-contrib/src/lib.rs
+++ b/minijinja-contrib/src/lib.rs
@@ -34,6 +34,7 @@ pub mod globals;
 pub fn add_to_environment(env: &mut Environment) {
     env.add_filter("pluralize", filters::pluralize);
     env.add_filter("filesizeformat", filters::filesizeformat);
+    env.add_filter("truncate", filters::truncate);
     #[cfg(feature = "datetime")]
     {
         env.add_filter("datetimeformat", filters::datetimeformat);
diff --git a/minijinja/src/defaults.rs b/minijinja/src/defaults.rs
index 20894dd..f70bb1f 100644
--- a/minijinja/src/defaults.rs
+++ b/minijinja/src/defaults.rs
@@ -91,6 +91,7 @@ pub(crate) fn get_builtin_filters() -> BTreeMap<Cow<'static, str>, filters::Boxe
         rv.insert("bool".into(), BoxedFilter::new(filters::bool));
         rv.insert("batch".into(), BoxedFilter::new(filters::batch));
         rv.insert("slice".into(), BoxedFilter::new(filters::slice));
+        rv.insert("sum".into(), BoxedFilter::new(filters::sum));
         rv.insert("indent".into(), BoxedFilter::new(filters::indent));
         rv.insert("select".into(), BoxedFilter::new(filters::select));
         rv.insert("reject".into(), BoxedFilter::new(filters::reject));
diff --git a/minijinja/src/filters.rs b/minijinja/src/filters.rs
index 69c8ad6..dc58351 100644
--- a/minijinja/src/filters.rs
+++ b/minijinja/src/filters.rs
@@ -294,7 +294,7 @@ mod builtins {
 
     use crate::error::ErrorKind;
     use crate::utils::splitn_whitespace;
-    use crate::value::ops::as_f64;
+    use crate::value::ops::{self, as_f64};
     use crate::value::{Enumerator, Kwargs, Object, ObjectRepr, ValueKind, ValueRepr};
     use std::borrow::Cow;
     use std::cmp::Ordering;
@@ -693,6 +693,31 @@ mod builtins {
         }
     }
 
+    /// Sums up all the values in a sequence.
+    ///
+    /// ```jinja
+    /// {{ range(10)|sum }} -> 45
+    /// ```
+    #[cfg_attr(docsrs, doc(cfg(feature = "builtins")))]
+    pub fn sum(state: &State, values: Value) -> Result<Value, Error> {
+        let mut rv = Value::from(0);
+        let iter = ok!(state.undefined_behavior().try_iter(values));
+        for value in iter {
+            if value.is_undefined() {
+                ok!(state.undefined_behavior().handle_undefined(false));
+                continue;
+            } else if !value.is_number() {
+                return Err(Error::new(
+                    ErrorKind::InvalidOperation,
+                    format!("can only sum numbers, got {}", value.kind()),
+                ));
+            }
+            rv = ok!(ops::add(&rv, &value));
+        }
+
+        Ok(rv)
+    }
+
     /// Looks up an attribute.
     ///
     /// In MiniJinja this is the same as the `[]` operator.  In Jinja2 there is a


Test patch:
diff --git a/minijinja-contrib/tests/filters.rs b/minijinja-contrib/tests/filters.rs
index 1019f15..0e2df55 100644
--- a/minijinja-contrib/tests/filters.rs
+++ b/minijinja-contrib/tests/filters.rs
@@ -128,3 +128,77 @@ fn test_filesizeformat() {
     insta::assert_snapshot!(render!(in env, r"{{ (1024 * 1024 * 1024 * 1024 * 1024 * 1024 * 1024 * 1024)|filesizeformat }}"), @"1.2 YB");
     insta::assert_snapshot!(render!(in env, r"{{ (1024 * 1024 * 1024 * 1024 * 1024 * 1024 * 1024 * 1024 * 1024 * 1024)|filesizeformat }}"), @"1267650.6 YB");
 }
+
+#[test]
+fn test_truncate() {
+    use minijinja::render;
+    use minijinja_contrib::filters::truncate;
+
+    const LONG_TEXT: &str = "Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum.";
+    const SHORT_TEXT: &str = "Fifteen chars !";
+    const SPECIAL_TEXT: &str = "Hello 👋 World";
+
+    let mut env = Environment::new();
+    env.add_filter("truncate", truncate);
+
+    insta::assert_snapshot!(
+        render!(in env, r"{{ text|truncate }}", text=>SHORT_TEXT),
+        @"Fifteen chars !"
+    );
+
+    insta::assert_snapshot!(
+        render!(in env, r"{{ text|truncate }}", text=>LONG_TEXT),
+        @"Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It..."
+    );
+
+    insta::assert_snapshot!(
+        render!(in env, r"{{ text|truncate(length=10) }}", text=>LONG_TEXT),
+        @"Lorem..."
+    );
+
+    insta::assert_snapshot!(
+        render!(in env, r"{{ text|truncate(length=10, killwords=true) }}", text=>LONG_TEXT),
+        @"Lorem I..."
+    );
+
+    insta::assert_snapshot!(
+        render!(in env, r"{{ text|truncate(length=10, end='***') }}", text=>LONG_TEXT),
+        @"Lorem***"
+    );
+
+    insta::assert_snapshot!(
+        render!(in env, r"{{ text|truncate(length=10, killwords=true, end='') }}", text=>LONG_TEXT),
+        @"Lorem Ipsu"
+    );
+
+    insta::assert_snapshot!(
+        render!(in env, r"{{ text|truncate(length=10, leeway=5) }}", text=>SHORT_TEXT),
+        @"Fifteen chars !"
+    );
+    insta::assert_snapshot!(
+        render!(in env, r"{{ text|truncate(length=10, leeway=0) }}", text=>SHORT_TEXT),
+        @"Fifteen..."
+    );
+
+    insta::assert_snapshot!(
+        render!(in env, r"{{ text|truncate(length=7, leeway=0, end='') }}", text=>SPECIAL_TEXT),
+        @"Hello"
+    );
+
+    insta::assert_snapshot!(
+        render!(in env, r"{{ text|truncate(length=7, leeway=0, end='', killwords=true) }}", text=>SPECIAL_TEXT),
+        @"Hello 👋"
+    );
+
+    insta::assert_snapshot!(
+        render!(in env, r"{{ text|truncate(length=8, leeway=0, end='') }}", text=>SPECIAL_TEXT),
+        @"Hello 👋"
+    );
+
+    assert_eq!(
+        env.render_str(r"{{ 'hello'|truncate(length=1) }}", context! {})
+            .unwrap_err()
+            .to_string(),
+        "invalid operation: expected length >= 3, got 1 (in <string>:1)"
+    );
+}
diff --git a/minijinja/tests/inputs/filters.txt b/minijinja/tests/inputs/filters.txt
index db9a534..928cace 100644
--- a/minijinja/tests/inputs/filters.txt
+++ b/minijinja/tests/inputs/filters.txt
@@ -112,5 +112,8 @@ split-at-and: {{ three_words|split(" and ")|list }}
 split-n-ws: {{ three_words|split(none, 1)|list }}
 split-n-d: {{ three_words|split("d", 1)|list }}
 split-n-ws-filter-empty: {{ "  foo    bar baz  "|split(none, 1)|list }}
+sum: {{ range(10)|sum }}
+sum-empty: {{ []|sum }}
+sum-float: {{ [0.5, 1.0]|sum }}
 lines: {{ "foo\nbar\r\nbaz"|lines }}
 string: {{ [1|string, 2|string] }}
\ No newline at end of file
diff --git a/minijinja/tests/snapshots/test_templates__vm@debug.txt.snap b/minijinja/tests/snapshots/test_templates__vm@debug.txt.snap
index c5b7b04..5777728 100644
--- a/minijinja/tests/snapshots/test_templates__vm@debug.txt.snap
+++ b/minijinja/tests/snapshots/test_templates__vm@debug.txt.snap
@@ -114,6 +114,7 @@ State {
             "sort",
             "split",
             "string",
+            "sum",
             "title",
             "tojson",
             "trim",
diff --git a/minijinja/tests/snapshots/test_templates__vm@filters.txt.snap b/minijinja/tests/snapshots/test_templates__vm@filters.txt.snap
index 98b9e08..e65e57a 100644
--- a/minijinja/tests/snapshots/test_templates__vm@filters.txt.snap
+++ b/minijinja/tests/snapshots/test_templates__vm@filters.txt.snap
@@ -1,6 +1,6 @@
 ---
 source: minijinja/tests/test_templates.rs
-description: "lower: {{ word|lower }}\nupper: {{ word|upper }}\ntitle: {{ word|title }}\ntitle-sentence: {{ \"the bIrd, is The:word\"|title }}\ntitle-three-words: {{ three_words|title }}\ncapitalize: {{ word|capitalize }}\ncapitalize-three-words: {{ three_words|capitalize }}\nreplace: {{ word|replace(\"B\", \"th\") }}\nescape: {{ \"<\"|escape }}\ne: {{ \"<\"|e }}\ndouble-escape: {{ \"<\"|escape|escape }}\nsafe: {{ \"<\"|safe|escape }}\nlist-length: {{ list|length }}\nlist-from-list: {{ list|list }}\nlist-from-map: {{ map|list }}\nlist-from-word: {{ word|list }}\nlist-from-undefined: {{ undefined|list }}\nbool-empty-string: {{ \"\"|bool }}\nbool-non-empty-string: {{ \"hello\"|bool }}\nbool-empty-list: {{ []|bool }}\nbool-non-empty-list: {{ [42]|bool }}\nbool-undefined: {{ undefined|bool }}\nmap-length: {{ map|length }}\nstring-length: {{ word|length }}\nstring-count: {{ word|count }}\nreverse-list: {{ list|reverse }}\nreverse-string: {{ word|reverse }}\ntrim: |{{ word_with_spaces|trim }}|\ntrim-bird: {{ word|trim(\"Bd\") }}\njoin-default: {{ list|join }}\njoin-pipe: {{ list|join(\"|\") }}\njoin_string: {{ word|join('-') }}\ndefault: {{ undefined|default == \"\" }}\ndefault-value: {{ undefined|default(42) }}\nfirst-list: {{ list|first }}\nfirst-word: {{ word|first }}\nfirst-undefined: {{ []|first is undefined }}\nlast-list: {{ list|last }}\nlast-word: {{ word|last }}\nlast-undefined: {{ []|first is undefined }}\nmin: {{ other_list|min }}\nmax: {{ other_list|max }}\nsort: {{ other_list|sort }}\nsort-reverse: {{ other_list|sort(reverse=true) }}\nsort-case-insensitive: {{ [\"B\", \"a\", \"C\", \"z\"]|sort }}\nsort-case-sensitive: {{ [\"B\", \"a\", \"C\", \"z\"]|sort(case_sensitive=true) }}\nsort-case-insensitive-mixed: {{ [0, 1, \"true\", \"false\", \"True\", \"False\", true, false]|sort }}\nsort-case-sensitive-mixed: {{ [0, 1, \"true\", \"false\", \"True\", \"False\", true, false]|sort(case_sensitive=true) }}\nsort-attribute {{ objects|sort(attribute=\"name\") }}\nd: {{ undefined|d == \"\" }}\njson: {{ map|tojson }}\njson-pretty: {{ map|tojson(true) }}\njson-scary-html: {{ scary_html|tojson }}\nurlencode: {{ \"hello world/foo-bar_baz.txt\"|urlencode }}\nurlencode-kv: {{ dict(a=\"x y\", b=2, c=3, d=None)|urlencode }}\nbatch: {{ range(10)|batch(3) }}\nbatch-fill: {{ range(10)|batch(3, '-') }}\nslice: {{ range(10)|slice(3) }}\nslice-fill: {{ range(10)|slice(3, '-') }}\nitems: {{ dict(a=1)|items }}\nindent: {{ \"foo\\nbar\\nbaz\"|indent(2)|tojson }}\nindent-first-line: {{ \"foo\\nbar\\nbaz\"|indent(2, true)|tojson }}\nint-abs: {{ -42|abs }}\nfloat-abs: {{ -42.5|abs }}\nint-round: {{ 42|round }}\nfloat-round: {{ 42.5|round }}\nfloat-round-prec2: {{ 42.512345|round(2) }}\nselect-odd: {{ [1, 2, 3, 4, 5, 6]|select(\"odd\") }}\nselect-truthy: {{ [undefined, null, 0, 42, 23, \"\", \"aha\"]|select }}\nreject-truthy: {{ [undefined, null, 0, 42, 23, \"\", \"aha\"]|reject }}\nreject-odd: {{ [1, 2, 3, 4, 5, 6]|reject(\"odd\") }}\nselect-attr: {{ [dict(active=true, key=1), dict(active=false, key=2)]|selectattr(\"active\") }}\nreject-attr: {{ [dict(active=true, key=1), dict(active=false, key=2)]|rejectattr(\"active\") }}\nselect-attr: {{ [dict(active=true, key=1), dict(active=false, key=2)]|selectattr(\"key\", \"even\") }}\nreject-attr: {{ [dict(active=true, key=1), dict(active=false, key=2)]|rejectattr(\"key\", \"even\") }}\nmap-maps: {{ [-1, -2, 3, 4, -5]|map(\"abs\") }}\nmap-attr: {{ [dict(a=1), dict(a=2), {}]|map(attribute='a', default=None) }}\nmap-attr-undefined: {{ [dict(a=1), dict(a=2), {}]|map(attribute='a', default=definitely_undefined) }}\nmap-attr-deep: {{ [dict(a=[1]), dict(a=[2]), dict(a=[])]|map(attribute='a.0', default=None) }}\nmap-attr-int: {{ [[1], [1, 2]]|map(attribute=1, default=999) }}\nattr-filter: {{ map|attr(\"a\") }}\nunique-filter: {{ [1, 1, 1, 4, 3, 0, 0, 5]|unique }}\nunique-filter-ci: {{ [\"a\", \"A\", \"b\", \"c\", \"b\", \"D\", \"d\"]|unique }}\nunique-filter-cs: {{ [\"a\", \"A\", \"b\", \"c\", \"b\", \"D\", \"d\"]|unique(case_sensitive=true) }}\nunique-attr-filter: {{ [{'x': 1}, {'x': 1, 'y': 2}, {'x': 2}]|unique }}\npprint-filter: {{ objects|pprint }}\nint-filter: {{ true|int }}, {{ \"42\"|int }}, {{ \"-23\"|int }}, {{ 42.0|int }}, {{ 42.42|int }}, {{ \"42.42\"|int }}\nfloat-filter: {{ true|float }}, {{ \"42\"|float }}, {{ \"-23.5\"|float }}, {{ 42.5|float }}\nsplit: {{ three_words|split|list }}\nsplit-at-and: {{ three_words|split(\" and \")|list }}\nsplit-n-ws: {{ three_words|split(none, 1)|list }}\nsplit-n-d: {{ three_words|split(\"d\", 1)|list }}\nsplit-n-ws-filter-empty: {{ \"  foo    bar baz  \"|split(none, 1)|list }}\nlines: {{ \"foo\\nbar\\r\\nbaz\"|lines }}\nstring: {{ [1|string, 2|string] }}"
+description: "lower: {{ word|lower }}\nupper: {{ word|upper }}\ntitle: {{ word|title }}\ntitle-sentence: {{ \"the bIrd, is The:word\"|title }}\ntitle-three-words: {{ three_words|title }}\ncapitalize: {{ word|capitalize }}\ncapitalize-three-words: {{ three_words|capitalize }}\nreplace: {{ word|replace(\"B\", \"th\") }}\nescape: {{ \"<\"|escape }}\ne: {{ \"<\"|e }}\ndouble-escape: {{ \"<\"|escape|escape }}\nsafe: {{ \"<\"|safe|escape }}\nlist-length: {{ list|length }}\nlist-from-list: {{ list|list }}\nlist-from-map: {{ map|list }}\nlist-from-word: {{ word|list }}\nlist-from-undefined: {{ undefined|list }}\nbool-empty-string: {{ \"\"|bool }}\nbool-non-empty-string: {{ \"hello\"|bool }}\nbool-empty-list: {{ []|bool }}\nbool-non-empty-list: {{ [42]|bool }}\nbool-undefined: {{ undefined|bool }}\nmap-length: {{ map|length }}\nstring-length: {{ word|length }}\nstring-count: {{ word|count }}\nreverse-list: {{ list|reverse }}\nreverse-string: {{ word|reverse }}\ntrim: |{{ word_with_spaces|trim }}|\ntrim-bird: {{ word|trim(\"Bd\") }}\njoin-default: {{ list|join }}\njoin-pipe: {{ list|join(\"|\") }}\njoin_string: {{ word|join('-') }}\ndefault: {{ undefined|default == \"\" }}\ndefault-value: {{ undefined|default(42) }}\nfirst-list: {{ list|first }}\nfirst-word: {{ word|first }}\nfirst-undefined: {{ []|first is undefined }}\nlast-list: {{ list|last }}\nlast-word: {{ word|last }}\nlast-undefined: {{ []|first is undefined }}\nmin: {{ other_list|min }}\nmax: {{ other_list|max }}\nsort: {{ other_list|sort }}\nsort-reverse: {{ other_list|sort(reverse=true) }}\nsort-case-insensitive: {{ [\"B\", \"a\", \"C\", \"z\"]|sort }}\nsort-case-sensitive: {{ [\"B\", \"a\", \"C\", \"z\"]|sort(case_sensitive=true) }}\nsort-case-insensitive-mixed: {{ [0, 1, \"true\", \"false\", \"True\", \"False\", true, false]|sort }}\nsort-case-sensitive-mixed: {{ [0, 1, \"true\", \"false\", \"True\", \"False\", true, false]|sort(case_sensitive=true) }}\nsort-attribute {{ objects|sort(attribute=\"name\") }}\nd: {{ undefined|d == \"\" }}\njson: {{ map|tojson }}\njson-pretty: {{ map|tojson(true) }}\njson-scary-html: {{ scary_html|tojson }}\nurlencode: {{ \"hello world/foo-bar_baz.txt\"|urlencode }}\nurlencode-kv: {{ dict(a=\"x y\", b=2, c=3, d=None)|urlencode }}\nbatch: {{ range(10)|batch(3) }}\nbatch-fill: {{ range(10)|batch(3, '-') }}\nslice: {{ range(10)|slice(3) }}\nslice-fill: {{ range(10)|slice(3, '-') }}\nitems: {{ dict(a=1)|items }}\nindent: {{ \"foo\\nbar\\nbaz\"|indent(2)|tojson }}\nindent-first-line: {{ \"foo\\nbar\\nbaz\"|indent(2, true)|tojson }}\nint-abs: {{ -42|abs }}\nfloat-abs: {{ -42.5|abs }}\nint-round: {{ 42|round }}\nfloat-round: {{ 42.5|round }}\nfloat-round-prec2: {{ 42.512345|round(2) }}\nselect-odd: {{ [1, 2, 3, 4, 5, 6]|select(\"odd\") }}\nselect-truthy: {{ [undefined, null, 0, 42, 23, \"\", \"aha\"]|select }}\nreject-truthy: {{ [undefined, null, 0, 42, 23, \"\", \"aha\"]|reject }}\nreject-odd: {{ [1, 2, 3, 4, 5, 6]|reject(\"odd\") }}\nselect-attr: {{ [dict(active=true, key=1), dict(active=false, key=2)]|selectattr(\"active\") }}\nreject-attr: {{ [dict(active=true, key=1), dict(active=false, key=2)]|rejectattr(\"active\") }}\nselect-attr: {{ [dict(active=true, key=1), dict(active=false, key=2)]|selectattr(\"key\", \"even\") }}\nreject-attr: {{ [dict(active=true, key=1), dict(active=false, key=2)]|rejectattr(\"key\", \"even\") }}\nmap-maps: {{ [-1, -2, 3, 4, -5]|map(\"abs\") }}\nmap-attr: {{ [dict(a=1), dict(a=2), {}]|map(attribute='a', default=None) }}\nmap-attr-undefined: {{ [dict(a=1), dict(a=2), {}]|map(attribute='a', default=definitely_undefined) }}\nmap-attr-deep: {{ [dict(a=[1]), dict(a=[2]), dict(a=[])]|map(attribute='a.0', default=None) }}\nmap-attr-int: {{ [[1], [1, 2]]|map(attribute=1, default=999) }}\nattr-filter: {{ map|attr(\"a\") }}\nunique-filter: {{ [1, 1, 1, 4, 3, 0, 0, 5]|unique }}\nunique-filter-ci: {{ [\"a\", \"A\", \"b\", \"c\", \"b\", \"D\", \"d\"]|unique }}\nunique-filter-cs: {{ [\"a\", \"A\", \"b\", \"c\", \"b\", \"D\", \"d\"]|unique(case_sensitive=true) }}\nunique-attr-filter: {{ [{'x': 1}, {'x': 1, 'y': 2}, {'x': 2}]|unique }}\npprint-filter: {{ objects|pprint }}\nint-filter: {{ true|int }}, {{ \"42\"|int }}, {{ \"-23\"|int }}, {{ 42.0|int }}, {{ 42.42|int }}, {{ \"42.42\"|int }}\nfloat-filter: {{ true|float }}, {{ \"42\"|float }}, {{ \"-23.5\"|float }}, {{ 42.5|float }}\nsplit: {{ three_words|split|list }}\nsplit-at-and: {{ three_words|split(\" and \")|list }}\nsplit-n-ws: {{ three_words|split(none, 1)|list }}\nsplit-n-d: {{ three_words|split(\"d\", 1)|list }}\nsplit-n-ws-filter-empty: {{ \"  foo    bar baz  \"|split(none, 1)|list }}\nsum: {{ range(10)|sum }}\nsum-empty: {{ []|sum }}\nsum-float: {{ [0.5, 1.0]|sum }}\nlines: {{ \"foo\\nbar\\r\\nbaz\"|lines }}\nstring: {{ [1|string, 2|string] }}"
 info:
   word: Bird
   word_with_spaces: " Spacebird\n"
@@ -127,5 +127,8 @@ split-at-and: ["bird", "dinosaur"]
 split-n-ws: ["bird", "and dinosaur"]
 split-n-d: ["bir", " and dinosaur"]
 split-n-ws-filter-empty: ["foo", "bar baz  "]
+sum: 45
+sum-empty: 0
+sum-float: 1.5
 lines: ["foo", "bar", "baz"]
 string: ["1", "2"]