Repository: mitsuhiko/minijinja
Pull request #687 (2025-01-28 11:53:37)

Add SemiStrict and improve Jinja2 compatibility
This makes to changes to Jinja2

1. The implicit `else` branch in an if expression will evaluate to a new `SilentUndefined` representation.  This is invisible from the perspective of an engine user.  Jinja2 does something similar where it will always use a default `Undefined` object which does not error on iteration of truthiness checks.
2. Adds a new `SemiStrict` undefined behavior which is like `Strict` but permits boolean evaluations.  Eg: `{% if undefined %}` does not error.

This addresses #680


wolfv: Thanks! I'll try it out. I was trying to work around things with an UndefinedTracker (from the examples), but hit a limitation with that approach since added functions are attempted to get loaded from the context first, before resolving from `env.get_global(...)` :)
wolfv: I just tried it out and it seems to work like charm. Thanks!
wolfv: I think I like `SemiStrict`. Or something like `StrictUndefinedExceptElse` or `StrictUndefinedSilentElse`. Or `SometimesSilentUndefined`. 

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c682b6e..6ac18fb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,13 @@
 
 All notable changes to MiniJinja are documented here.
 
+## 2.8.0
+
+- Added `SemiStrict` undefined mode that is like strict but allows
+  to be checked for truthiness.  Additionally an if expression without
+  an else block will always produce a silent undefined object that
+  never errors for compatibility with Jinja2.  #687
+
 ## 2.7.0
 
 - Removed string interning.  #675
diff --git a/minijinja/src/compiler/codegen.rs b/minijinja/src/compiler/codegen.rs
index db718be..ba8364f 100644
--- a/minijinja/src/compiler/codegen.rs
+++ b/minijinja/src/compiler/codegen.rs
@@ -7,7 +7,7 @@ use crate::compiler::instructions::{
 use crate::compiler::tokens::Span;
 use crate::output::CaptureMode;
 use crate::value::ops::neg;
-use crate::value::{Kwargs, Value, ValueMap};
+use crate::value::{Kwargs, Value, ValueMap, ValueRepr};
 
 #[cfg(test)]
 use similar_asserts::assert_eq;
@@ -660,7 +660,10 @@ impl<'source> CodeGenerator<'source> {
                 if let Some(ref false_expr) = i.false_expr {
                     self.compile_expr(false_expr);
                 } else {
-                    self.add(Instruction::LoadConst(Value::UNDEFINED));
+                    // special behavior: missing false block have a silent undefined
+                    // to permit special casing.  This is for compatibility also with
+                    // what Jinja2 does.
+                    self.add(Instruction::LoadConst(ValueRepr::SilentUndefined.into()));
                 }
                 self.end_if();
             }
diff --git a/minijinja/src/environment.rs b/minijinja/src/environment.rs
index 8fe1b2d..25f3e75 100644
--- a/minijinja/src/environment.rs
+++ b/minijinja/src/environment.rs
@@ -13,7 +13,7 @@ use crate::expression::Expression;
 use crate::output::Output;
 use crate::template::{CompiledTemplate, CompiledTemplateRef, Template, TemplateConfig};
 use crate::utils::{AutoEscape, BTreeMapKeysDebug, UndefinedBehavior};
-use crate::value::{FunctionArgs, FunctionResult, Value};
+use crate::value::{FunctionArgs, FunctionResult, Value, ValueRepr};
 use crate::vm::State;
 use crate::{defaults, filters, functions, tests};
 
@@ -796,10 +796,14 @@ impl<'source> Environment<'source> {
         state: &State,
         out: &mut Output,
     ) -> Result<(), Error> {
-        if value.is_undefined() && matches!(self.undefined_behavior, UndefinedBehavior::Strict) {
-            Err(Error::from(ErrorKind::UndefinedError))
-        } else {
-            (self.formatter)(out, state, value)
+        match (self.undefined_behavior, &value.0) {
+            // this intentionally does not check for SilentUndefined.  SilentUndefined is
+            // exclusively used in the missing else condition of an if expression to match
+            // Jinja2 behavior.  Those go straight to the formatter.
+            (UndefinedBehavior::Strict | UndefinedBehavior::SemiStrict, &ValueRepr::Undefined) => {
+                Err(Error::from(ErrorKind::UndefinedError))
+            }
+            _ => (self.formatter)(out, state, value),
         }
     }
 
diff --git a/minijinja/src/filters.rs b/minijinja/src/filters.rs
index 143e7cd..c4e3666 100644
--- a/minijinja/src/filters.rs
+++ b/minijinja/src/filters.rs
@@ -640,7 +640,9 @@ mod builtins {
     #[cfg_attr(docsrs, doc(cfg(feature = "builtins")))]
     pub fn int(value: &Value) -> Result<Value, Error> {
         match &value.0 {
-            ValueRepr::Undefined | ValueRepr::None => Ok(Value::from(0)),
+            ValueRepr::Undefined | ValueRepr::SilentUndefined | ValueRepr::None => {
+                Ok(Value::from(0))
+            }
             ValueRepr::Bool(x) => Ok(Value::from(*x as u64)),
             ValueRepr::U64(_) | ValueRepr::I64(_) | ValueRepr::U128(_) | ValueRepr::I128(_) => {
                 Ok(value.clone())
@@ -673,7 +675,9 @@ mod builtins {
     #[cfg_attr(docsrs, doc(cfg(feature = "builtins")))]
     pub fn float(value: &Value) -> Result<Value, Error> {
         match &value.0 {
-            ValueRepr::Undefined | ValueRepr::None => Ok(Value::from(0.0)),
+            ValueRepr::Undefined | ValueRepr::SilentUndefined | ValueRepr::None => {
+                Ok(Value::from(0.0))
+            }
             ValueRepr::Bool(x) => Ok(Value::from(*x as u64 as f64)),
             ValueRepr::String(..) | ValueRepr::SmallStr(_) => value
                 .as_str()
@@ -1190,7 +1194,9 @@ mod builtins {
             Ok(rv)
         } else {
             match &value.0 {
-                ValueRepr::None | ValueRepr::Undefined => Ok("".into()),
+                ValueRepr::None | ValueRepr::Undefined | ValueRepr::SilentUndefined => {
+                    Ok("".into())
+                }
                 ValueRepr::Bytes(b) => Ok(percent_encoding::percent_encode(b, SET).to_string()),
                 ValueRepr::String(..) | ValueRepr::SmallStr(_) => Ok(
                     percent_encoding::utf8_percent_encode(value.as_str().unwrap(), SET).to_string(),
diff --git a/minijinja/src/functions.rs b/minijinja/src/functions.rs
index fd6bb74..08cdff9 100644
--- a/minijinja/src/functions.rs
+++ b/minijinja/src/functions.rs
@@ -336,7 +336,7 @@ mod builtins {
         let mut rv = match value {
             None => ValueMap::default(),
             Some(value) => match value.0 {
-                ValueRepr::Undefined => ValueMap::default(),
+                ValueRepr::Undefined | ValueRepr::SilentUndefined => ValueMap::default(),
                 ValueRepr::Object(obj) if obj.repr() == ObjectRepr::Map => {
                     obj.try_iter_pairs().into_iter().flatten().collect()
                 }
diff --git a/minijinja/src/syntax.rs b/minijinja/src/syntax.rs
index 7415ca4..1bd6d2a 100644
--- a/minijinja/src/syntax.rs
+++ b/minijinja/src/syntax.rs
@@ -180,6 +180,15 @@
 //! {{ title|upper if title }}
 //! ```
 //!
+//! Note that for compatibility with Jinja2, when the `else` block is missing the undefined
+//! value will be marked as "silent".  This means even if strict undefined behavior is
+//! requested, this undefined value will print to an empty string.  This means
+//! that this is always valid:
+//!
+//! ```jinja
+//! {{ value if false }} -> prints an empty string (silent undefined returned from else)
+//! ```
+//!
 //! # Tags
 //!
 //! Tags control logic in templates.  The following tags exist:
diff --git a/minijinja/src/utils.rs b/minijinja/src/utils.rs
index 007dca8..b74da09 100644
--- a/minijinja/src/utils.rs
+++ b/minijinja/src/utils.rs
@@ -106,8 +106,9 @@ pub enum AutoEscape {
 
 /// Defines the behavior of undefined values in the engine.
 ///
-/// At present there are three types of behaviors available which mirror the behaviors
-/// that Jinja2 provides out of the box.
+/// At present there are three types of behaviors available which mirror the
+/// behaviors that Jinja2 provides out of the box and an extra option called
+/// `SemiStrict` which is a slightly less strict undefined.
 #[derive(Debug, Copy, Clone, PartialEq, Eq, Default)]
 #[non_exhaustive]
 pub enum UndefinedBehavior {
@@ -116,6 +117,7 @@ pub enum UndefinedBehavior {
     /// * **printing:** allowed (returns empty string)
     /// * **iteration:** allowed (returns empty array)
     /// * **attribute access of undefined values:** fails
+    /// * **if true:** allowed (is considered false)
     #[default]
     Lenient,
     /// Like `Lenient`, but also allows chaining of undefined lookups.
@@ -123,12 +125,21 @@ pub enum UndefinedBehavior {
     /// * **printing:** allowed (returns empty string)
     /// * **iteration:** allowed (returns empty array)
     /// * **attribute access of undefined values:** allowed (returns [`undefined`](Value::UNDEFINED))
+    /// * **if true:** allowed (is considered false)
     Chainable,
+    /// Like strict, but does not error when the undefined is checked for truthyness.
+    ///
+    /// * **printing:** fails
+    /// * **iteration:** fails
+    /// * **attribute access of undefined values:** fails
+    /// * **if true:** allowed (is considered false)
+    SemiStrict,
     /// Complains very quickly about undefined values.
     ///
     /// * **printing:** fails
     /// * **iteration:** fails
     /// * **attribute access of undefined values:** fails
+    /// * **if true:** fails
     Strict,
 }
 
@@ -136,18 +147,17 @@ impl UndefinedBehavior {
     /// Utility method used in the engine to determine what to do when an undefined is
     /// encountered.
     ///
-    /// The flag indicates if this is the first or second level of undefined value.  If
-    /// `parent_was_undefined` is set to `true`, the undefined was created by looking up
-    /// a missing attribute on an undefined value.  If `false` the undefined was created by
-    /// looking up a missing attribute on a defined value.
+    /// The flag indicates if this is the first or second level of undefined value.  The
+    /// parent value is passed too.
     pub(crate) fn handle_undefined(self, parent_was_undefined: bool) -> Result<Value, Error> {
         match (self, parent_was_undefined) {
             (UndefinedBehavior::Lenient, false)
             | (UndefinedBehavior::Strict, false)
+            | (UndefinedBehavior::SemiStrict, false)
             | (UndefinedBehavior::Chainable, _) => Ok(Value::UNDEFINED),
-            (UndefinedBehavior::Lenient, true) | (UndefinedBehavior::Strict, true) => {
-                Err(Error::from(ErrorKind::UndefinedError))
-            }
+            (UndefinedBehavior::Lenient, true)
+            | (UndefinedBehavior::Strict, true)
+            | (UndefinedBehavior::SemiStrict, true) => Err(Error::from(ErrorKind::UndefinedError)),
         }
     }
 
@@ -156,10 +166,12 @@ impl UndefinedBehavior {
     /// This fails only for strict undefined values.
     #[inline]
     pub(crate) fn is_true(self, value: &Value) -> Result<bool, Error> {
-        if matches!(self, UndefinedBehavior::Strict) && value.is_undefined() {
-            Err(Error::from(ErrorKind::UndefinedError))
-        } else {
-            Ok(value.is_true())
+        match (self, &value.0) {
+            // silent undefined doesn't error, even in strict mode
+            (UndefinedBehavior::Strict, &ValueRepr::Undefined) => {
+                Err(Error::from(ErrorKind::UndefinedError))
+            }
+            _ => Ok(value.is_true()),
         }
     }
 
@@ -176,10 +188,12 @@ impl UndefinedBehavior {
     /// Are we strict on iteration?
     #[inline]
     pub(crate) fn assert_iterable(self, value: &Value) -> Result<(), Error> {
-        if matches!(self, UndefinedBehavior::Strict) && value.is_undefined() {
-            Err(Error::from(ErrorKind::UndefinedError))
-        } else {
-            Ok(())
+        match (self, &value.0) {
+            // silent undefined doesn't error, even in strict mode
+            (UndefinedBehavior::Strict | UndefinedBehavior::SemiStrict, &ValueRepr::Undefined) => {
+                Err(Error::from(ErrorKind::UndefinedError))
+            }
+            _ => Ok(()),
         }
     }
 }
diff --git a/minijinja/src/value/argtypes.rs b/minijinja/src/value/argtypes.rs
index 630b354..701d6c7 100644
--- a/minijinja/src/value/argtypes.rs
+++ b/minijinja/src/value/argtypes.rs
@@ -937,7 +937,9 @@ impl TryFrom<Value> for Kwargs {
 
     fn try_from(value: Value) -> Result<Self, Self::Error> {
         match value.0 {
-            ValueRepr::Undefined => Ok(Kwargs::new(Default::default())),
+            ValueRepr::Undefined | ValueRepr::SilentUndefined => {
+                Ok(Kwargs::new(Default::default()))
+            }
             ValueRepr::Object(_) => {
                 Kwargs::extract(&value).ok_or_else(|| Error::from(ErrorKind::InvalidOperation))
             }
diff --git a/minijinja/src/value/deserialize.rs b/minijinja/src/value/deserialize.rs
index 4ebcdbb..941bf0a 100644
--- a/minijinja/src/value/deserialize.rs
+++ b/minijinja/src/value/deserialize.rs
@@ -181,7 +181,9 @@ impl<'de> Deserializer<'de> for Value {
             ValueRepr::F64(v) => visitor.visit_f64(v),
             ValueRepr::String(ref v, _) => visitor.visit_str(v),
             ValueRepr::SmallStr(v) => visitor.visit_str(v.as_str()),
-            ValueRepr::Undefined | ValueRepr::None => visitor.visit_unit(),
+            ValueRepr::Undefined | ValueRepr::SilentUndefined | ValueRepr::None => {
+                visitor.visit_unit()
+            }
             ValueRepr::Bytes(ref v) => visitor.visit_bytes(v),
             ValueRepr::Object(o) => match o.repr() {
                 ObjectRepr::Plain => Err(de::Error::custom("cannot deserialize plain objects")),
@@ -197,7 +199,9 @@ impl<'de> Deserializer<'de> for Value {
 
     fn deserialize_option<V: Visitor<'de>>(self, visitor: V) -> Result<V::Value, Error> {
         match self.0 {
-            ValueRepr::None | ValueRepr::Undefined => visitor.visit_unit(),
+            ValueRepr::None | ValueRepr::Undefined | ValueRepr::SilentUndefined => {
+                visitor.visit_unit()
+            }
             _ => visitor.visit_some(self),
         }
     }
@@ -405,7 +409,7 @@ impl de::Error for Error {
 
 fn value_to_unexpected(value: &Value) -> Unexpected {
     match value.0 {
-        ValueRepr::Undefined | ValueRepr::None => Unexpected::Unit,
+        ValueRepr::Undefined | ValueRepr::SilentUndefined | ValueRepr::None => Unexpected::Unit,
         ValueRepr::Bool(val) => Unexpected::Bool(val),
         ValueRepr::U64(val) => Unexpected::Unsigned(val),
         ValueRepr::I64(val) => Unexpected::Signed(val),
diff --git a/minijinja/src/value/mod.rs b/minijinja/src/value/mod.rs
index dc41a7c..2f5166b 100644
--- a/minijinja/src/value/mod.rs
+++ b/minijinja/src/value/mod.rs
@@ -410,7 +410,11 @@ impl SmallStr {
 
 #[derive(Clone)]
 pub(crate) enum ValueRepr {
+    /// The regular undefined type produced as part of template evaluation
     Undefined,
+    /// A special undefined marker that indicates an always-quiet undefined.
+    /// This is emitted for ternary expressions with missing else blocks.
+    SilentUndefined,
     Bool(bool),
     U64(u64),
     I64(i64),
@@ -428,7 +432,7 @@ pub(crate) enum ValueRepr {
 impl fmt::Debug for ValueRepr {
     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
         match *self {
-            ValueRepr::Undefined => f.write_str("undefined"),
+            ValueRepr::Undefined | ValueRepr::SilentUndefined => f.write_str("undefined"),
             ValueRepr::Bool(ref val) => fmt::Debug::fmt(val, f),
             ValueRepr::U64(ref val) => fmt::Debug::fmt(val, f),
             ValueRepr::I64(ref val) => fmt::Debug::fmt(val, f),
@@ -458,7 +462,7 @@ impl fmt::Debug for ValueRepr {
 impl Hash for Value {
     fn hash<H: Hasher>(&self, state: &mut H) {
         match self.0 {
-            ValueRepr::None | ValueRepr::Undefined => 0u8.hash(state),
+            ValueRepr::None | ValueRepr::Undefined | ValueRepr::SilentUndefined => 0u8.hash(state),
             ValueRepr::String(ref s, _) => s.hash(state),
             ValueRepr::SmallStr(ref s) => s.as_str().hash(state),
             ValueRepr::Bool(b) => b.hash(state),
@@ -488,7 +492,10 @@ impl PartialEq for Value {
     fn eq(&self, other: &Self) -> bool {
         match (&self.0, &other.0) {
             (&ValueRepr::None, &ValueRepr::None) => true,
-            (&ValueRepr::Undefined, &ValueRepr::Undefined) => true,
+            (
+                &ValueRepr::Undefined | &ValueRepr::SilentUndefined,
+                &ValueRepr::Undefined | &ValueRepr::SilentUndefined,
+            ) => true,
             (&ValueRepr::String(ref a, _), &ValueRepr::String(ref b, _)) => a == b,
             (&ValueRepr::SmallStr(ref a), &ValueRepr::SmallStr(ref b)) => a.as_str() == b.as_str(),
             (&ValueRepr::Bytes(ref a), &ValueRepr::Bytes(ref b)) => a == b,
@@ -578,7 +585,10 @@ impl Ord for Value {
         }
         match (&self.0, &other.0) {
             (&ValueRepr::None, &ValueRepr::None) => Ordering::Equal,
-            (&ValueRepr::Undefined, &ValueRepr::Undefined) => Ordering::Equal,
+            (
+                &ValueRepr::Undefined | &ValueRepr::SilentUndefined,
+                &ValueRepr::Undefined | &ValueRepr::SilentUndefined,
+            ) => Ordering::Equal,
             (&ValueRepr::String(ref a, _), &ValueRepr::String(ref b, _)) => a.cmp(b),
             (&ValueRepr::SmallStr(ref a), &ValueRepr::SmallStr(ref b)) => {
                 a.as_str().cmp(b.as_str())
@@ -632,7 +642,7 @@ impl fmt::Debug for Value {
 impl fmt::Display for Value {
     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
         match self.0 {
-            ValueRepr::Undefined => Ok(()),
+            ValueRepr::Undefined | ValueRepr::SilentUndefined => Ok(()),
             ValueRepr::Bool(val) => val.fmt(f),
             ValueRepr::U64(val) => val.fmt(f),
             ValueRepr::I64(val) => val.fmt(f),
@@ -1043,7 +1053,7 @@ impl Value {
     /// perform operations on it.
     pub fn kind(&self) -> ValueKind {
         match self.0 {
-            ValueRepr::Undefined => ValueKind::Undefined,
+            ValueRepr::Undefined | ValueRepr::SilentUndefined => ValueKind::Undefined,
             ValueRepr::Bool(_) => ValueKind::Bool,
             ValueRepr::U64(_) | ValueRepr::I64(_) | ValueRepr::F64(_) => ValueKind::Number,
             ValueRepr::None => ValueKind::None,
@@ -1107,7 +1117,10 @@ impl Value {
             ValueRepr::String(ref x, _) => !x.is_empty(),
             ValueRepr::SmallStr(ref x) => !x.is_empty(),
             ValueRepr::Bytes(ref x) => !x.is_empty(),
-            ValueRepr::None | ValueRepr::Undefined | ValueRepr::Invalid(_) => false,
+            ValueRepr::None
+            | ValueRepr::Undefined
+            | ValueRepr::SilentUndefined
+            | ValueRepr::Invalid(_) => false,
             ValueRepr::Object(ref x) => x.is_true(),
         }
     }
@@ -1119,7 +1132,7 @@ impl Value {
 
     /// Returns `true` if this value is undefined.
     pub fn is_undefined(&self) -> bool {
-        matches!(&self.0, ValueRepr::Undefined)
+        matches!(&self.0, ValueRepr::Undefined | ValueRepr::SilentUndefined)
     }
 
     /// Returns `true` if this value is none.
@@ -1220,7 +1233,9 @@ impl Value {
     /// ```
     pub fn get_attr(&self, key: &str) -> Result<Value, Error> {
         let value = match self.0 {
-            ValueRepr::Undefined => return Err(Error::from(ErrorKind::UndefinedError)),
+            ValueRepr::Undefined | ValueRepr::SilentUndefined => {
+                return Err(Error::from(ErrorKind::UndefinedError))
+            }
             ValueRepr::Object(ref dy) => dy.get_value(&Value::from(key)),
             _ => None,
         };
@@ -1271,7 +1286,7 @@ impl Value {
     /// assert_eq!(value.to_string(), "Foo");
     /// ```
     pub fn get_item(&self, key: &Value) -> Result<Value, Error> {
-        if let ValueRepr::Undefined = self.0 {
+        if let ValueRepr::Undefined | ValueRepr::SilentUndefined = self.0 {
             Err(Error::from(ErrorKind::UndefinedError))
         } else {
             Ok(self.get_item_opt(key).unwrap_or(Value::UNDEFINED))
@@ -1305,7 +1320,9 @@ impl Value {
     /// ```
     pub fn try_iter(&self) -> Result<ValueIter, Error> {
         match self.0 {
-            ValueRepr::None | ValueRepr::Undefined => Some(ValueIterImpl::Empty),
+            ValueRepr::None | ValueRepr::Undefined | ValueRepr::SilentUndefined => {
+                Some(ValueIterImpl::Empty)
+            }
             ValueRepr::String(ref s, _) => {
                 Some(ValueIterImpl::Chars(0, s.chars().count(), Arc::clone(s)))
             }
@@ -1336,7 +1353,9 @@ impl Value {
     ///   reversible itself, it consumes it and then reverses it.
     pub fn reverse(&self) -> Result<Value, Error> {
         match self.0 {
-            ValueRepr::Undefined | ValueRepr::None => Some(self.clone()),
+            ValueRepr::Undefined | ValueRepr::SilentUndefined | ValueRepr::None => {
+                Some(self.clone())
+            }
             ValueRepr::String(ref s, _) => Some(Value::from(s.chars().rev().collect::<String>())),
             ValueRepr::SmallStr(ref s) => {
                 // TODO: add small str optimization here
@@ -1627,9 +1646,10 @@ impl Serialize for Value {
             ValueRepr::U64(u) => serializer.serialize_u64(u),
             ValueRepr::I64(i) => serializer.serialize_i64(i),
             ValueRepr::F64(f) => serializer.serialize_f64(f),
-            ValueRepr::None | ValueRepr::Undefined | ValueRepr::Invalid(_) => {
-                serializer.serialize_unit()
-            }
+            ValueRepr::None
+            | ValueRepr::Undefined
+            | ValueRepr::SilentUndefined
+            | ValueRepr::Invalid(_) => serializer.serialize_unit(),
             ValueRepr::U128(u) => serializer.serialize_u128(u.0),
             ValueRepr::I128(i) => serializer.serialize_i128(i.0),
             ValueRepr::String(ref s, _) => serializer.serialize_str(s),
diff --git a/minijinja/src/value/ops.rs b/minijinja/src/value/ops.rs
index 082a4d3..531b35d 100644
--- a/minijinja/src/value/ops.rs
+++ b/minijinja/src/value/ops.rs
@@ -134,7 +134,9 @@ pub fn slice(value: Value, start: Value, stop: Value, step: Value) -> Result<Val
                 b.get(start..start + len).unwrap_or_default().to_owned(),
             ))
         }
-        ValueRepr::Undefined | ValueRepr::None => Ok(Value::from(Vec::<Value>::new())),
+        ValueRepr::Undefined | ValueRepr::SilentUndefined | ValueRepr::None => {
+            Ok(Value::from(Vec::<Value>::new()))
+        }
         ValueRepr::Object(obj) if matches!(obj.repr(), ObjectRepr::Seq | ObjectRepr::Iterable) => {
             Ok(Value::make_object_iterable(obj, move |obj| {
                 let len = obj.enumerator_len().unwrap_or_default();


Test patch:
diff --git a/minijinja/tests/test_undefined.rs b/minijinja/tests/test_undefined.rs
index c231dfe..0131fda 100644
--- a/minijinja/tests/test_undefined.rs
+++ b/minijinja/tests/test_undefined.rs
@@ -38,6 +38,68 @@ fn test_lenient_undefined() {
     assert_eq!(render!(in env, "{{ 42 in undefined }}"), "false");
 }
 
+#[test]
+fn test_semi_strict_undefined() {
+    let mut env = Environment::new();
+    env.set_undefined_behavior(UndefinedBehavior::SemiStrict);
+
+    assert_eq!(
+        env.render_str("{{ true.missing_attribute }}", ())
+            .unwrap_err()
+            .kind(),
+        ErrorKind::UndefinedError
+    );
+    assert_eq!(
+        env.render_str("{{ undefined.missing_attribute }}", ())
+            .unwrap_err()
+            .kind(),
+        ErrorKind::UndefinedError
+    );
+    assert_eq!(
+        env.render_str("<{% for x in undefined %}...{% endfor %}>", ())
+            .unwrap_err()
+            .kind(),
+        ErrorKind::UndefinedError
+    );
+    assert_eq!(
+        env.render_str("{{ 'foo' is in(undefined) }}", ())
+            .unwrap_err()
+            .kind(),
+        ErrorKind::UndefinedError
+    );
+    assert_eq!(render!(in env, "<{% if undefined %}42{% endif %}>"), "<>");
+    assert_eq!(
+        env.render_str("<{{ undefined }}>", ()).unwrap_err().kind(),
+        ErrorKind::UndefinedError
+    );
+    assert_eq!(render!(in env, "{{ undefined is undefined }}"), "true");
+    assert_eq!(render!(in env, "<{{ 42 if false }}>"), "<>");
+    assert_eq!(
+        render!(in env, "{{ x.foo is undefined }}", x => HashMap::<String, String>::new()),
+        "true"
+    );
+    assert_eq!(
+        env.render_str(
+            "<{% if x.foo %}...{% endif %}>",
+            context! { x => HashMap::<String, String>::new() }
+        )
+        .unwrap(),
+        "<>"
+    );
+    assert_eq!(
+        env.render_str("{{ undefined|list }}", ())
+            .unwrap_err()
+            .kind(),
+        ErrorKind::InvalidOperation
+    );
+    assert_eq!(
+        env.render_str("{{ 42 in undefined }}", ())
+            .unwrap_err()
+            .kind(),
+        ErrorKind::UndefinedError
+    );
+}
+
 #[test]
 fn test_strict_undefined() {
     let mut env = Environment::new();
@@ -67,11 +129,18 @@ fn test_strict_undefined() {
             .kind(),
         ErrorKind::UndefinedError
     );
+    assert_eq!(
+        env.render_str("<{% if undefined %}42{% endif %}>", ())
+            .unwrap_err()
+            .kind(),
+        ErrorKind::UndefinedError
+    );
     assert_eq!(
         env.render_str("<{{ undefined }}>", ()).unwrap_err().kind(),
         ErrorKind::UndefinedError
     );
     assert_eq!(render!(in env, "{{ undefined is undefined }}"), "true");
+    assert_eq!(env.render_str("<{{ 42 if false }}>", ()).unwrap(), "<>");
     assert_eq!(
         render!(in env, "{{ x.foo is undefined }}", x => HashMap::<String, String>::new()),
         "true"