Repository: mitsuhiko/minijinja (language: rust)

Issue:
feat request: namespace support
Hi @mitsuhiko thank you for this awesome project.

We are currently using this library in https://github.com/huggingface/text-generation-inference to parse chat templates and need namespace support to fully make use of templates. 

Is there a plan to add namespace support in the near future? 

### Future state
example syntax
```jinja
{% set ns = namespace(found=false) %}
{% for item in birds %}
  {% if item.type == "goose" %}
    {% set ns.found = true %}
  {% endif %}
{% endfor %}

Found the goose {{ ns.found }}
```

with input
```json
{
  "birds": [
    {
      "type": "duck"
    },
    {
      "type": "duck"
    },
    {
      "type": "goose"
    }
  ]
}
```

would output
```
Found the goose true
```


### Current State
but currently the most similar (incorrect) syntax does not return true

```jinja
{% set found = false %}
{% for item in birds %}
  {% if item.type == "goose" %}
    {% set found = true %}
  {% endif %}
{% endfor %}
Found the goose {{ found }}
```

```
Found the goose false
```

Thank you!


Discussion / hints:

Fix patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8845815d..626695e7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,9 @@ All notable changes to MiniJinja are documented here.
 - Added support for `Error::display_debug_info` which displays just the
   debug info, same way as alternative display on the error does.  #420
 
+- Added the `namspace()` function from Jinja2 and the ability to assign
+  to it via `{% set %}`.  #422
+
 ## 1.0.12
 
 - The `urlencode` filter now correctly skips over none and undefined.  #394
diff --git a/minijinja/src/compiler/codegen.rs b/minijinja/src/compiler/codegen.rs
index 12aae266..694243a1 100644
--- a/minijinja/src/compiler/codegen.rs
+++ b/minijinja/src/compiler/codegen.rs
@@ -525,6 +525,11 @@ impl<'source> CodeGenerator<'source> {
                 }
                 self.pop_span();
             }
+            ast::Expr::GetAttr(attr) => {
+                self.push_span(attr.span());
+                self.compile_expr(&attr.expr);
+                self.add(Instruction::SetAttr(attr.name));
+            }
             _ => unreachable!(),
         }
     }
diff --git a/minijinja/src/compiler/instructions.rs b/minijinja/src/compiler/instructions.rs
index 26212c62..d1f04a29 100644
--- a/minijinja/src/compiler/instructions.rs
+++ b/minijinja/src/compiler/instructions.rs
@@ -42,6 +42,9 @@ pub enum Instruction<'source> {
     /// Looks up an attribute.
     GetAttr(&'source str),
 
+    /// Sets an attribute.
+    SetAttr(&'source str),
+
     /// Looks up an item.
     GetItem,
 
diff --git a/minijinja/src/compiler/parser.rs b/minijinja/src/compiler/parser.rs
index 2b946b16..4f695d02 100644
--- a/minijinja/src/compiler/parser.rs
+++ b/minijinja/src/compiler/parser.rs
@@ -681,7 +681,18 @@ impl<'a> Parser<'a> {
         if RESERVED_NAMES.contains(&id) {
             syntax_error!("cannot assign to reserved variable name {}", id);
         }
-        Ok(ast::Expr::Var(ast::Spanned::new(ast::Var { id }, span)))
+        let mut rv = ast::Expr::Var(ast::Spanned::new(ast::Var { id }, span));
+        while skip_token!(self, Token::Dot) {
+            let (attr, span) = expect_token!(self, Token::Ident(name) => name, "identifier");
+            rv = ast::Expr::GetAttr(ast::Spanned::new(
+                ast::GetAttr {
+                    expr: rv,
+                    name: attr,
+                },
+                span,
+            ));
+        }
+        Ok(rv)
     }
 
     fn parse_assignment(&mut self) -> Result<ast::Expr<'a>, Error> {
diff --git a/minijinja/src/defaults.rs b/minijinja/src/defaults.rs
index c2f12ddc..28634a34 100644
--- a/minijinja/src/defaults.rs
+++ b/minijinja/src/defaults.rs
@@ -184,6 +184,10 @@ pub(crate) fn get_globals() -> BTreeMap<Cow<'static, str>, Value> {
             "debug".into(),
             BoxedFunction::new(functions::debug).to_value(),
         );
+        rv.insert(
+            "namespace".into(),
+            BoxedFunction::new(functions::namespace).to_value(),
+        );
     }
 
     rv
diff --git a/minijinja/src/functions.rs b/minijinja/src/functions.rs
index ad72e3c3..caea85e5 100644
--- a/minijinja/src/functions.rs
+++ b/minijinja/src/functions.rs
@@ -264,7 +264,7 @@ mod builtins {
     use super::*;
 
     use crate::error::ErrorKind;
-    use crate::value::{MapType, ObjectKind, Rest, ValueMap, ValueRepr};
+    use crate::value::{MapType, ObjectKind, Rest, ValueKind, ValueMap, ValueRepr};
 
     /// Returns a range.
     ///
@@ -389,6 +389,39 @@ mod builtins {
             format!("{:#?}", &args.0[..])
         }
     }
+
+    /// Creates a new container that allows attribute assignment using the `{% set %}` tag.
+    ///
+    /// ```jinja
+    /// {% set ns = namespace() %}
+    /// {% set ns.foo = 'bar' %}
+    /// ```
+    ///
+    /// The main purpose of this is to allow carrying a value from within a loop body
+    /// to an outer scope. Initial values can be provided as a dict, as keyword arguments,
+    /// or both (same behavior as [`dict`]).
+    #[cfg_attr(docsrs, doc(cfg(feature = "builtins")))]
+    pub fn namespace(defaults: Option<Value>) -> Result<Value, Error> {
+        let ns = crate::value::namespace_object::Namespace::default();
+        if let Some(defaults) = defaults {
+            if defaults.kind() == ValueKind::Map {
+                for key in ok!(defaults.try_iter()) {
+                    if let Some(key) = key.as_str() {
+                        ns.set_field(key, ok!(defaults.get_attr(key)));
+                    }
+                }
+            } else {
+                return Err(Error::new(
+                    ErrorKind::InvalidOperation,
+                    format!(
+                        "expected object or keyword arguments, got {}",
+                        defaults.kind()
+                    ),
+                ));
+            }
+        }
+        Ok(Value::from_struct_object(ns))
+    }
 }
 
 #[cfg(feature = "builtins")]
diff --git a/minijinja/src/syntax.rs b/minijinja/src/syntax.rs
index 47570341..eae94f1f 100644
--- a/minijinja/src/syntax.rs
+++ b/minijinja/src/syntax.rs
@@ -504,6 +504,24 @@
 //! {% set title | upper %}Title of the page{% endset %}
 //! ```
 //!
+//! More complex use cases can be handled using namespace objects which allow
+//! propagating of changes across scopes:
+//!
+//! ```jinja
+//! {% set ns = namespace(found=false) %}
+//! {% for item in items %}
+//!   {% if item.check_something() %}
+//!     {% set ns.found = true %}
+//!   {% endif %}
+//!   * {{ item.title }}
+//! {% endfor %}
+//! Found item having something: {{ ns.found }}
+//! ```
+//!
+//! Note that the `obj.attr` notation in the set tag is only allowed for namespace
+//! objects; attempting to assign an attribute on any other object will cause
+//! an error.
+//!
 //! ## `{% filter %}`
 //!
 //! Filter sections allow you to apply regular [filters](crate::filters) on a
diff --git a/minijinja/src/value/mod.rs b/minijinja/src/value/mod.rs
index c2d103aa..bcdadb31 100644
--- a/minijinja/src/value/mod.rs
+++ b/minijinja/src/value/mod.rs
@@ -152,6 +152,7 @@ mod argtypes;
 mod deserialize;
 mod keyref;
 pub(crate) mod merge_object;
+pub(crate) mod namespace_object;
 mod object;
 pub(crate) mod ops;
 mod serialize;
diff --git a/minijinja/src/value/namespace_object.rs b/minijinja/src/value/namespace_object.rs
new file mode 100644
index 00000000..1c10218d
--- /dev/null
+++ b/minijinja/src/value/namespace_object.rs
@@ -0,0 +1,34 @@
+use std::collections::BTreeMap;
+use std::sync::{Arc, Mutex};
+
+use crate::value::{StructObject, Value};
+
+/// This object exists for the `namespace` function.
+///
+/// It's special in that it behaves like a dictionary in many ways but it's the only
+/// object that can be used with `{% set %}` assignments.  This is used internally
+/// in the vm via downcasting.
+#[derive(Default)]
+pub(crate) struct Namespace {
+    data: Mutex<BTreeMap<Arc<str>, Value>>,
+}
+
+impl StructObject for Namespace {
+    fn get_field(&self, name: &str) -> Option<Value> {
+        self.data.lock().unwrap().get(name).cloned()
+    }
+
+    fn fields(&self) -> Vec<Arc<str>> {
+        self.data.lock().unwrap().keys().cloned().collect()
+    }
+
+    fn field_count(&self) -> usize {
+        self.data.lock().unwrap().len()
+    }
+}
+
+impl Namespace {
+    pub(crate) fn set_field(&self, key: &str, value: Value) {
+        self.data.lock().unwrap().insert(key.into(), value);
+    }
+}
diff --git a/minijinja/src/vm/mod.rs b/minijinja/src/vm/mod.rs
index 167ffd95..6797bdcd 100644
--- a/minijinja/src/vm/mod.rs
+++ b/minijinja/src/vm/mod.rs
@@ -10,6 +10,7 @@ use crate::environment::Environment;
 use crate::error::{Error, ErrorKind};
 use crate::output::{CaptureMode, Output};
 use crate::utils::{untrusted_size_hint, AutoEscape, UndefinedBehavior};
+use crate::value::namespace_object::Namespace;
 use crate::value::{
     ops, value_map_with_capacity, value_optimization, KeyRef, MapType, Value, ValueRepr,
 };
@@ -321,6 +322,18 @@ impl<'env> Vm<'env> {
                         None => ctx_ok!(undefined_behavior.handle_undefined(a.is_undefined())),
                     });
                 }
+                Instruction::SetAttr(name) => {
+                    b = stack.pop();
+                    a = stack.pop();
+                    if let Some(ns) = b.as_object().and_then(|x| x.downcast_ref::<Namespace>()) {
+                        ns.set_field(name, a);
+                    } else {
+                        bail!(Error::new(
+                            ErrorKind::InvalidOperation,
+                            format!("can only assign to namespaces, not {}", b.kind())
+                        ));
+                    }
+                }
                 Instruction::GetItem => {
                     a = stack.pop();
                     b = stack.pop();


Test patch:
diff --git a/minijinja/tests/inputs/namespace.txt b/minijinja/tests/inputs/namespace.txt
new file mode 100644
index 00000000..79040ca4
--- /dev/null
+++ b/minijinja/tests/inputs/namespace.txt
@@ -0,0 +1,19 @@
+{
+}
+---
+{%- set ns = namespace() %}
+{%- set ns.foo = 0 %}
+{%- for count in range(10) %}
+  {%- set ns.foo = ns.foo + count %}
+{%- endfor %}
+{{- ns }}
+---
+{% set ns.foo = namespace() %}
+{%- set ns.foo.bar = 42 %}
+{{- ns }}
+---
+{% set ns = namespace(found=true) %}
+{{- ns }}
+---
+{% set ns = namespace({"found": true}) %}
+{{- ns }}
\ No newline at end of file
diff --git a/minijinja/tests/inputs/namespace_bad.txt b/minijinja/tests/inputs/namespace_bad.txt
new file mode 100644
index 00000000..114bc472
--- /dev/null
+++ b/minijinja/tests/inputs/namespace_bad.txt
@@ -0,0 +1,4 @@
+{
+}
+---
+{{ namespace(42) }}
\ 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 b0769451..f2e09b2f 100644
--- a/minijinja/tests/snapshots/test_templates__vm@debug.txt.snap
+++ b/minijinja/tests/snapshots/test_templates__vm@debug.txt.snap
@@ -31,6 +31,7 @@ State {
         globals: {
             "debug": minijinja::functions::builtins::debug,
             "dict": minijinja::functions::builtins::dict,
+            "namespace": minijinja::functions::builtins::namespace,
             "range": minijinja::functions::builtins::range,
         },
         tests: [
diff --git a/minijinja/tests/snapshots/test_templates__vm@namespace.txt.snap b/minijinja/tests/snapshots/test_templates__vm@namespace.txt.snap
new file mode 100644
index 00000000..4c4c06ea
--- /dev/null
+++ b/minijinja/tests/snapshots/test_templates__vm@namespace.txt.snap
@@ -0,0 +1,14 @@
+---
+source: minijinja/tests/test_templates.rs
+description: "{%- set ns = namespace() %}\n{%- set ns.foo = 0 %}\n{%- for count in range(10) %}\n  {%- set ns.foo = ns.foo + count %}\n{%- endfor %}\n{{- ns }}\n---\n{% set ns.foo = namespace() %}\n{%- set ns.foo.bar = 42 %}\n{{- ns }}\n---\n{% set ns = namespace(found=true) %}\n{{- ns }}\n---\n{% set ns = namespace({\"found\": true}) %}\n{{- ns }}"
+info: {}
+input_file: minijinja/tests/inputs/namespace.txt
+---
+{"foo": 45}
+---
+{"foo": {"bar": 42}}
+---
+{"found": true}
+---
+{"found": true}
+
diff --git a/minijinja/tests/snapshots/test_templates__vm@namespace_bad.txt.snap b/minijinja/tests/snapshots/test_templates__vm@namespace_bad.txt.snap
new file mode 100644
index 00000000..4ae9806d
--- /dev/null
+++ b/minijinja/tests/snapshots/test_templates__vm@namespace_bad.txt.snap
@@ -0,0 +1,25 @@
+---
+source: minijinja/tests/test_templates.rs
+description: "{{ namespace(42) }}"
+info: {}
+input_file: minijinja/tests/inputs/namespace_bad.txt
+---
+!!!ERROR!!!
+
+Error {
+    kind: InvalidOperation,
+    detail: "expected object or keyword arguments, got number",
+    name: "namespace_bad.txt",
+    line: 1,
+}
+
+invalid operation: expected object or keyword arguments, got number (in namespace_bad.txt:1)
+------------------------------ namespace_bad.txt ------------------------------
+   1 > {{ namespace(42) }}
+     i    ^^^^^^^^^^^^^ invalid operation
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+Referenced variables: {
+    namespace: minijinja::functions::builtins::namespace,
+}
+-------------------------------------------------------------------------------
+