Repository: mitsuhiko/minijinja (Rust)

Issue:
Filter in child template replaces other filter in parent template
## Description

It's probably best to just see the reproduction. As far as I can tell, using `{% set foo = "value" | <filter> %}` in a template that extends another affects filters in the base template.

## Reproduction steps

Rust minimum working example with `minijinja = "=2.1.1"` in `Cargo.toml`:

```rust
fn main() {
    let mut env = minijinja::Environment::empty();

    env.add_filter("reverse", minijinja::filters::reverse);
    env.add_filter("nop", |value: String| value);

    const ONE: &str = r#"{{ "foobar" | nop }}"#;
    const TWO: &str = r#"{% extends "one.html" %}{% set foo = "" | reverse %}"#;

    env.add_template("one.html", ONE).unwrap();
    env.add_template("two.html", TWO).unwrap();

    let template = env.get_template("one.html").unwrap();
    println!("{}", template.render(minijinja::context! {}).unwrap());

    let template = env.get_template("two.html").unwrap();
    println!("{}", template.render(minijinja::context! {}).unwrap());
}
```

This prints "foobar" and "raboof" (!).

It does not matter where the set statement is, so this also produces the same result:

```rust
const TWO: &str = r#"{% set foo = "" | reverse %}{% extends "one.html" %}"#;
```

For completeness, Python/Jinja2 3.1.4 minimum working example:

```python
from jinja2 import Environment, DictLoader
loader = DictLoader({
    "one.html": '{{ "foobar" | nop }}',
    "two.html": '{% extends "one.html" %}{% set foo = "" | reverse %}',
})
env = Environment(loader=loader)
env.filters["nop"] = lambda v: v
print(env.get_template("one.html").render())
print(env.get_template("two.html").render())
```

Prints `foobar` and `foobar` as expected.

Additional helpful information:

- Version of minijinja: 2.1.1 (f4bf71af278c578cbcc91d0b1ff092910208bc86f7b3750364642bd424e3dcd3), 2.1.0, 2.0.0, 1.0.21, 0.30.0, 0.26.0
- Version of rustc: 1.80.0 (051478957 2024-07-21), 1.79.0 (129f3b996 2024-06-10), 1.76.0 (07dca489a 2024-02-04)
- Operating system and version: macOS Sonoma 14.5 (23F79)

As far as I can see, 0.25.0 works as expected, and 0.26.0 introduced this behaviour.

## What did you expect

I'd expect the example to print "foobar" in both cases, as the Python example and version 0.25.0 does.




Discussion:
I think I have a bit of a handle on this. The templates are parsed to this:

```
Template {
    name: "one.html",
    instructions: [
        00000 | LoadConst("foobar")  [line 1],
        00001 | ApplyFilter("nop", 1, 0),
        00002 | Emit,
    ],
    blocks: {},
    initial_auto_escape: None,
}
Template {
    name: "two.html",
    instructions: [
        00000 | LoadConst("")  [line 1],
        00001 | ApplyFilter("reverse", 1, 0),
        00002 | StoreLocal("foo"),
        00003 | LoadConst("one.html"),
        00004 | LoadBlocks,
    ],
    blocks: {},
    initial_auto_escape: None,
}
```

Simplifying wildly, `LoadBlocks` basically causes the instructions to be appended:

```
LoadConst(""),
ApplyFilter("reverse", 1, 0),
StoreLocal("foo"),
LoadConst("one.html"),
LoadBlocks ->
    LoadConst("foobar"),
    ApplyFilter("nop", 1, 0),
    Emit,
```

The problem is that both `ApplyFilter` instructions have the same `local_id` of `0`, and that filter lookups are cached via `loaded_filters` / `get_or_lookup_local` in the VM. Unfortunately, this causes the incorrect filter to be returned.

If I modify the VM to flush `loaded_filters` when a `LoadBlocks` instruction is processed, or when `parent_instructions` are loaded, then the issue goes away. Tests suffer from the same issue due to `loaded_tests`:

```rust
fn main() {
    let mut env = minijinja::Environment::empty();

    env.add_test("odd", minijinja::tests::is_odd);
    env.add_test("even", minijinja::tests::is_even);

    const ONE: &str = "{{ 1 is odd }}";
    const TWO: &str = "{% set foo = 1 is even %}{% extends 'one.html' %}";

    env.add_template("one.html", ONE).unwrap();
    env.add_template("two.html", TWO).unwrap();

    let template = env.get_template("one.html").unwrap();
    println!("{}", template.render(minijinja::context! {}).unwrap());

    let template = env.get_template("two.html").unwrap();
    println!("{}", template.render(minijinja::context! {}).unwrap());
}
```

This prints `true` and `false`. I'll try and prepare a PR.
Just for obviousness, a `set` statement is not required. Any statement that uses a filter or a test can trigger this, e.g.:
```rust
const ONE: &str = "{{ 'foobar' | nop }}";
const TWO: &str = "{% extends 'one.html' %}{{ '' | reverse }}";
```
Well spotted.  I will apply a slightly modified version of your PR.



Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f2b9f178..de8083dc 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,12 @@
 
 All notable changes to MiniJinja are documented here.
 
+## 2.1.2
+
+- Flush filter and test cache when processing extended template.
+  This fixes a bug that caused the wrong filters to be used in some
+  cases. #551
+
 ## 2.1.1
 
 - Added `indent` parameter to `tojson` filter.  #546
diff --git a/minijinja/src/vm/mod.rs b/minijinja/src/vm/mod.rs
index bebe4ca4..06bb4d03 100644
--- a/minijinja/src/vm/mod.rs
+++ b/minijinja/src/vm/mod.rs
@@ -211,7 +211,7 @@ impl<'env> Vm<'env> {
             }};
         }
 
-        // looks nicer nice way
+        // looks nicer this way
         #[allow(clippy::while_let_loop)]
         loop {
             let instr = match state.instructions.get(pc) {
@@ -231,6 +231,11 @@ impl<'env> Vm<'env> {
                     };
                     out.end_capture(AutoEscape::None);
                     pc = 0;
+                    // because we swap out the instructions we also need to unload all
+                    // the filters and tests to ensure that we are not accidentally
+                    // reusing the local_ids for completely different filters.
+                    loaded_filters = [None; MAX_LOCALS];
+                    loaded_tests = [None; MAX_LOCALS];
                     continue;
                 }
             };


Test patch:
diff --git a/minijinja/tests/test_templates.rs b/minijinja/tests/test_templates.rs
index 6a039498..f3fe5d71 100644
--- a/minijinja/tests/test_templates.rs
+++ b/minijinja/tests/test_templates.rs
@@ -629,3 +629,32 @@ fn test_multiple_extended_includes_in_loop() {
     let rv = env.get_template("main.txt").unwrap().render(()).unwrap();
     assert_eq!(rv, "012");
 }
+
+/// See https://github.com/mitsuhiko/minijinja/issues/551
+#[test]
+fn test_filter_caching() {
+    let mut env = Environment::new();
+    env.add_template("parent.txt", "{{ 'Hello Foo' | lower }}")
+        .unwrap();
+    env.add_template(
+        "child.txt",
+        "{% extends 'parent.txt' %}{% set dummy = 'a' | upper %}",
+    )
+    .unwrap();
+    let rv = env.get_template("child.txt").unwrap().render(()).unwrap();
+    assert_eq!(rv, "hello foo");
+}
+
+/// See https://github.com/mitsuhiko/minijinja/issues/551
+#[test]
+fn test_test_caching() {
+    let mut env = Environment::new();
+    env.add_template("parent.txt", "{{ 42 is odd }}").unwrap();
+    env.add_template(
+        "child.txt",
+        "{% extends 'parent.txt' %}{% set dummy = 23 is even %}",
+    )
+    .unwrap();
+    let rv = env.get_template("child.txt").unwrap().render(()).unwrap();
+    assert_eq!(rv, "false");
+}