Repository: mitsuhiko/minijinja
Pull request #475 (2024-04-05 07:28:21)

Added the iterable test from Jinja2
This adds the iterable test from Jinja2 and adds a default `UnknownMethod` error message.


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6b69028..a399418 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -37,6 +37,17 @@ particular when implementing dynamic objects.  In short:
 - The `Syntax` type has been replaced by the `SyntaxConfig` type.  It uses a builder
   patter to reconfigure the delimiters.
 
+**Other Changes:**
+
+- Added a new `Environment::templates` method that iterates over loaded templates.  #471
+- Reverse iteration and slicing now return iterables instead of real sequences.
+- The engine no longer reports iterable as sequences.
+- The value iterator returned by `Value::try_iter` now holds a reference
+  to the `Value` internally via reference counting.
+- `DynObject` now replaces `Arc<Object>`.
+- The debug printing of some objects was simplified.
+- Added the `iterable` test.  #475
+
 ## 1.0.17
 
 - Added support for `Option<Into<Value>>` as return value from
diff --git a/examples/dsl/src/main.rs b/examples/dsl/src/main.rs
index d2cc9ec..f1d0fb1 100644
--- a/examples/dsl/src/main.rs
+++ b/examples/dsl/src/main.rs
@@ -71,10 +71,7 @@ impl Object for Query {
                 let (offset,) = from_args(args)?;
                 Ok(Value::from_object(self.offset(offset)))
             }
-            _ => Err(minijinja::Error::new(
-                minijinja::ErrorKind::UnknownMethod,
-                format!("unknown query filter method {name}"),
-            )),
+            _ => Err(minijinja::Error::from(minijinja::ErrorKind::UnknownMethod)),
         }
     }
 
diff --git a/examples/dynamic-objects/src/main.rs b/examples/dynamic-objects/src/main.rs
index 9b92bbb..ed808bf 100644
--- a/examples/dynamic-objects/src/main.rs
+++ b/examples/dynamic-objects/src/main.rs
@@ -42,10 +42,7 @@ impl Object for Magic {
             let (tag,): (&str,) = from_args(args)?;
             Ok(Value::from(format!("magic-{tag}")))
         } else {
-            Err(Error::new(
-                minijinja::ErrorKind::UnknownMethod,
-                format!("object has no method named {name}"),
-            ))
+            Err(Error::from(minijinja::ErrorKind::UnknownMethod))
         }
     }
 }
diff --git a/minijinja/src/defaults.rs b/minijinja/src/defaults.rs
index 28634a3..6584f31 100644
--- a/minijinja/src/defaults.rs
+++ b/minijinja/src/defaults.rs
@@ -128,6 +128,7 @@ pub(crate) fn get_builtin_tests() -> BTreeMap<Cow<'static, str>, BoxedTest> {
         rv.insert("float".into(), BoxedTest::new(tests::is_float));
         rv.insert("string".into(), BoxedTest::new(tests::is_string));
         rv.insert("sequence".into(), BoxedTest::new(tests::is_sequence));
+        rv.insert("iterable".into(), BoxedTest::new(tests::is_iterable));
         rv.insert("mapping".into(), BoxedTest::new(tests::is_mapping));
         rv.insert(
             "startingwith".into(),
diff --git a/minijinja/src/environment.rs b/minijinja/src/environment.rs
index a1257be..44a02fa 100644
--- a/minijinja/src/environment.rs
+++ b/minijinja/src/environment.rs
@@ -312,10 +312,7 @@ impl<'source> Environment<'source> {
     ///         let _: () = from_args(args)?;
     ///         state.apply_filter("items", &[value.clone()])
     ///     } else {
-    ///         Err(Error::new(
-    ///             ErrorKind::UnknownMethod,
-    ///             "object has no method named {name}",
-    ///         ))
+    ///         Err(Error::from(ErrorKind::UnknownMethod))
     ///     }
     /// });
     /// ```
diff --git a/minijinja/src/value/mod.rs b/minijinja/src/value/mod.rs
index cbd3eb7..d4255a1 100644
--- a/minijinja/src/value/mod.rs
+++ b/minijinja/src/value/mod.rs
@@ -956,7 +956,8 @@ impl Value {
     /// has a different behavior.
     ///
     /// * [`ValueKind::Map`]: the iterator yields the keys of the map.
-    /// * [`ValueKind::Seq`]: the iterator yields the items in the sequence.
+    /// * [`ValueKind::Seq`] / [`ValueKind::Iterable`]: the iterator yields the items in the sequence.
+    /// * [`ValueKind::String`]: the iterator yields characters in a string.
     /// * [`ValueKind::None`] / [`ValueKind::Undefined`]: the iterator is empty.
     ///
     /// ```
@@ -1190,10 +1191,12 @@ impl Value {
     pub fn call_method(&self, state: &State, name: &str, args: &[Value]) -> Result<Value, Error> {
         match self._call_method(state, name, args) {
             Ok(rv) => Ok(rv),
-            Err(err) => {
+            Err(mut err) => {
                 if err.kind() == ErrorKind::UnknownMethod {
                     if let Some(ref callback) = state.env().unknown_method_callback {
                         return callback(state, self, name, args);
+                    } else if err.detail().is_none() {
+                        err.set_detail(format!("{} has no method named {}", self.kind(), name));
                     }
                 }
                 Err(err)
@@ -1205,10 +1208,7 @@ impl Value {
         if let Some(object) = self.as_object() {
             object.call_method(state, name, args)
         } else {
-            Err(Error::new(
-                ErrorKind::UnknownMethod,
-                format!("object has no method named {name}"),
-            ))
+            Err(Error::from(ErrorKind::UnknownMethod))
         }
     }
 
diff --git a/minijinja/src/value/object.rs b/minijinja/src/value/object.rs
index 5cb803a..90904dd 100644
--- a/minijinja/src/value/object.rs
+++ b/minijinja/src/value/object.rs
@@ -235,10 +235,7 @@ pub trait Object: fmt::Debug + Send + Sync {
             return value.call(state, args);
         }
 
-        Err(Error::new(
-            ErrorKind::UnknownMethod,
-            "object has no such method",
-        ))
+        Err(Error::from(ErrorKind::UnknownMethod))
     }
 
     /// Formats the object for stringification.
diff --git a/minijinja/src/vm/loop_object.rs b/minijinja/src/vm/loop_object.rs
index e3a2f7f..d3f5783 100644
--- a/minijinja/src/vm/loop_object.rs
+++ b/minijinja/src/vm/loop_object.rs
@@ -56,10 +56,7 @@ impl Object for Loop {
                 None => Ok(Value::UNDEFINED),
             }
         } else {
-            Err(Error::new(
-                ErrorKind::UnknownMethod,
-                format!("loop object has no method named {name}"),
-            ))
+            Err(Error::from(ErrorKind::UnknownMethod))
         }
     }
 


Test patch:
diff --git a/minijinja/src/tests.rs b/minijinja/src/tests.rs
index 013979e..2aeb1e5 100644
--- a/minijinja/src/tests.rs
+++ b/minijinja/src/tests.rs
@@ -324,6 +324,17 @@ mod builtins {
         matches!(v.kind(), ValueKind::Seq)
     }
 
+    /// Checks if this value can be iterated over.
+    ///
+    /// ```jinja
+    /// {{ [1, 2, 3] is iterable }} -> true
+    /// {{ 42 is iterable }} -> false
+    /// ```
+    #[cfg_attr(docsrs, doc(cfg(feature = "builtins")))]
+    pub fn is_iterable(v: Value) -> bool {
+        v.try_iter().is_ok()
+    }
+
     /// Checks if this value is a mapping
     ///
     /// ```jinja
diff --git a/minijinja/tests/inputs/tests.txt b/minijinja/tests/inputs/tests.txt
index 38b704b..5b58851 100644
--- a/minijinja/tests/inputs/tests.txt
+++ b/minijinja/tests/inputs/tests.txt
@@ -28,6 +28,11 @@ float-int: {{ 42 is float }}
 float-float: {{ 42.0 is float }}
 not-seq: {{ two is sequence }}
 seq: {{ seq is sequence }}
+reverse-not-seq: {{ seq|reverse is sequence }}
+iterable: {{ seq is iterable }}
+iterable-reverse: {{ seq|reverse is iterable }}
+string-iterable: {{ string is iterable }}
+not-iterable: {{ two is iterable }}
 not-map: {{ two is mapping }}
 map: {{ map is mapping }}
 string: {{ string is string }}
diff --git a/minijinja/tests/snapshots/test_templates__vm@debug.txt.snap b/minijinja/tests/snapshots/test_templates__vm@debug.txt.snap
index 55dd6ed..85b7dae 100644
--- a/minijinja/tests/snapshots/test_templates__vm@debug.txt.snap
+++ b/minijinja/tests/snapshots/test_templates__vm@debug.txt.snap
@@ -50,6 +50,7 @@ State {
             "in",
             "int",
             "integer",
+            "iterable",
             "le",
             "lessthan",
             "lt",
diff --git a/minijinja/tests/snapshots/test_templates__vm@tests.txt.snap b/minijinja/tests/snapshots/test_templates__vm@tests.txt.snap
index ad41df4..03a613c 100644
--- a/minijinja/tests/snapshots/test_templates__vm@tests.txt.snap
+++ b/minijinja/tests/snapshots/test_templates__vm@tests.txt.snap
@@ -1,6 +1,6 @@
 ---
 source: minijinja/tests/test_templates.rs
-description: "even: {{ two is even }}\nodd: {{ two is odd }}\nundefined: {{ two is undefined }}\ndefined: {{ two is defined }}\nundefined2: {{ ohwell is undefined }}\ndefined2: {{ ohwell is defined }}\nnone: {{ none is none }}\nnot-none: {{ 42 is not none }}\nnumber-int: {{ two is number }}\nnumber-float: {{ two_dot_two is number }}\ninteger-int: {{ 42 is integer }}\ninteger-float: {{ 42.0 is integer }}\nfloat-int: {{ 42 is float }}\nfloat-float: {{ 42.0 is float }}\nnot-seq: {{ two is sequence }}\nseq: {{ seq is sequence }}\nnot-map: {{ two is mapping }}\nmap: {{ map is mapping }}\nstring: {{ string is string }}\nnot-string: {{ mapping is string }}\nstarts-with-a: {{ string is startingwith('a') }}\nends-with-ha: {{ string is endingwith('ha') }}\nnot-safe: {{ \"foo\" is safe }}\nsafe: {{ \"foo\"|escape is safe }}\nis-true: {{ true is true }} | {{ 42 is true }}\nis-false: {{ false is false }} | {{ 0 is false }}\nis-filter: {{ 'escape' is filter }} | {{ 'unknown-filter' is filter }}\nis-test: {{ 'safe' is test }} | {{ 'unknown-test' is test }}"
+description: "even: {{ two is even }}\nodd: {{ two is odd }}\nundefined: {{ two is undefined }}\ndefined: {{ two is defined }}\nundefined2: {{ ohwell is undefined }}\ndefined2: {{ ohwell is defined }}\nnone: {{ none is none }}\nnot-none: {{ 42 is not none }}\nnumber-int: {{ two is number }}\nnumber-float: {{ two_dot_two is number }}\ninteger-int: {{ 42 is integer }}\ninteger-float: {{ 42.0 is integer }}\nfloat-int: {{ 42 is float }}\nfloat-float: {{ 42.0 is float }}\nnot-seq: {{ two is sequence }}\nseq: {{ seq is sequence }}\nreverse-not-seq: {{ seq|reverse is sequence }}\niterable: {{ seq is iterable }}\niterable-reverse: {{ seq|reverse is iterable }}\nstring-iterable: {{ string is iterable }}\nnot-iterable: {{ two is iterable }}\nnot-map: {{ two is mapping }}\nmap: {{ map is mapping }}\nstring: {{ string is string }}\nnot-string: {{ mapping is string }}\nstarts-with-a: {{ string is startingwith('a') }}\nends-with-ha: {{ string is endingwith('ha') }}\nnot-safe: {{ \"foo\" is safe }}\nsafe: {{ \"foo\"|escape is safe }}\nis-true: {{ true is true }} | {{ 42 is true }}\nis-false: {{ false is false }} | {{ 0 is false }}\nis-filter: {{ 'escape' is filter }} | {{ 'unknown-filter' is filter }}\nis-test: {{ 'safe' is test }} | {{ 'unknown-test' is test }}"
 info:
   map:
     foo: bar
@@ -29,6 +29,11 @@ float-int: false
 float-float: true
 not-seq: false
 seq: true
+reverse-not-seq: false
+iterable: true
+iterable-reverse: true
+string-iterable: true
+not-iterable: false
 not-map: false
 map: true
 string: true
@@ -41,4 +46,3 @@ is-true: true | false
 is-false: true | false
 is-filter: true | false
 is-test: true | false
-
diff --git a/minijinja/tests/test_environment.rs b/minijinja/tests/test_environment.rs
index 7136402..043b119 100644
--- a/minijinja/tests/test_environment.rs
+++ b/minijinja/tests/test_environment.rs
@@ -163,10 +163,7 @@ fn test_unknown_method_callback() {
             from_args(args)?;
             minijinja::filters::items(value.clone())
         } else {
-            Err(Error::new(
-                ErrorKind::UnknownMethod,
-                "object has no method named {name}",
-            ))
+            Err(Error::from(ErrorKind::UnknownMethod))
         }
     });