Skip to content

Conversation

@jasmith-hs
Copy link
Contributor

@jasmith-hs jasmith-hs commented Mar 18, 2023

Depends on #1030
There was a problem which I found after #1020, which made this bug easier to happen, in which a variable would be reconstructed, but it would reside only within the macro scope. If that variable was defined outside of the macro function, and the macro function modifies that variable, then it is problematic.
I wanted to make changes to the way that we evaluate macro functions in eager execution, and I didn't like having all the eager execution logic mixed in with the non-eager logic so I separated it into EagerMacroFunction, which I reworked to extend MacroFunction. I also added some deduping to the deferred lazy reference logic, and the prefix to preserve state logic by making it into a LinkedHashMap rather than just a string, which would allow me to remove the duplicates and override the reconstructed version for a string. (Deferred lazy references made it complicated because you'd want them reconstructed before anything else, but you wouldn't want the same key to have a different reconstruction later in the same PrefixToPreserveString. That's where the LinkedHashMap deduping helped).


I also needed to change how we put deferred values onto the context to be more accurate, and how we reset speculative bindings. The gist of these changes are that a DeferredValueShadow is used to mark that a value was deferred on that context scope (or a child of it), but it isn't the actual source of the value. Once we find the source of the value, we stop deferring. To visualise, that looks like this, imagine 1 is the highest level context (global) and 5 is the lowest level context.
A variable foo is set. Then in a child scope, another foo is set, but it resides in a child scope. The context starts looking like this:

1. {}
-2. {"foo": "my first foo value"}
--3. {"foo": ["a"]}
---4. {}
----5. {}

Then if when on context level 5, we end up having a deferred token which does this in deferred execution mode: {% do foo.append('b') %}, we'd have foo as a usedDeferredWord in the DeferredToken so we'd end up with the context looking like this after the DeferredToken is handled on the context:

1. {}
-2. {"foo": "my first foo value"}
--3. {"foo": DeferredValue(["a"])}
---4. {"foo": DeferredValueShadow(["a"])}
----5. {"foo": DeferredValueShadow(["a"])}

The DeferredValueShadow lets us reset bindings properly as before we were just replacing the lowest-level existing value with the original value ["a"], but now we easily revert the context back to what it was previously if we need to by removing the DeferredValueShadows for foo and replacing 3:foo with its original value.


The big simplification of what I needed to change in the eager execution macro function logic was to make it more like how we are eagerly evaluating a for loop. It needs to run in a child context using EagerContextWatcher so that we can determine if it is fully resolved and track the modified variables properly. Currently, it runs in its own scope, but it wasn't using EagerContextWatcher so modified variables weren't getting reconstructed properly. This is the current logic:

try (InterpreterScopeClosable c = interpreter.enterScope()) {
String result = getEvaluationResult(argMap, kwargMap, varArgs, interpreter);
if (
!interpreter.getContext().getDeferredNodes().isEmpty() ||
!interpreter.getContext().getDeferredTokens().isEmpty()
) {
if (!interpreter.getContext().isPartialMacroEvaluation()) {
String tempVarName = MacroFunctionTempVariable.getVarName(
getName(),
hashCode(),
currentCallCount
);
interpreter
.getContext()
.getParent()
.put(tempVarName, new MacroFunctionTempVariable(result));
throw new DeferredParsingException(this, tempVarName);
}
if (interpreter.getContext().isDeferredExecutionMode()) {
return EagerReconstructionUtils.wrapInChildScope(result, interpreter);
}
}
return result;

Here's an example:

{% macro foo(var) %}
{% do my_list.append(var) %}
{% endmacro %}
{% macro append_stuff() %}
{{ foo('b') }}
{% endmacro %}
{% set my_list = [] %}
{% if deferred %}
{{ append_stuff() }}
{% endif %}
{{ my_list }}

Currently, this gets output like:

{% set my_list = [] %}{% if deferred %}
{% set my_list = [] %}{% for __ignored__ in [0] %}
{% set my_list = [] %}{% set my_list = [] %}{% for __ignored__ in [0] %}
{% do my_list.append('b') %}
{% endfor %}
{% endfor %}
{% endif %}
{{ my_list }}

Oops! We are creating my_list in a child scope, which means that it's different from the original my_list, so the final {{ my_list }} won't have b inside of it.

After these changes, it is output like this, which is much better (and correct):

{% set my_list = [] %}{% if deferred %}
{% set my_list = [] %}{% set __macro_append_stuff_153654787_temp_variable_0__ %}
{% set __macro_foo_97643642_temp_variable_1__ %}
{% do my_list.append('b') %}
{% endset %}{{ __macro_foo_97643642_temp_variable_1__ }}
{% endset %}{{ __macro_append_stuff_153654787_temp_variable_0__ }}
{% endif %}
{{ my_list }}

The if statement isn't creating a new scope, so we are still modifying the original my_list when calling my_list.append('b'), which is what we want so that {{ my_list }} is output as ['b'] when rendering a second pass.


Eager execution is tricky when entering into a child scope (such as is done with macro functions, for tags, and set blocks) so I tried to reuse some of the logic between them. Also, deferred evaluation of if-branches has to similarly reset the speculative bindings. But they are each quite different:

  • for loops create a new variable in the scope, and can run multiple times
  • macro functions are not tags themselves, and can be called within other tags or expressions. They can also be recursive. They also define multiple input parameters that are only present in the current scope
  • set blocks are pretty straightforward. They're a good baseline for how to handle eager execution in a child scope

@jasmith-hs jasmith-hs changed the title Track modified variables better in eager macro function execution Track modified variables better in child-scoped eager execution Mar 18, 2023
I realised that what I was trying to achieve by running them twice is getting the necessary values deferred so that they just wouldn't be included in a reconstructed set tag that was within a child scope.
But if we just filter out the DeferredMacroShadows when creating the PrefixToPreserveState, we can omit those set tags with just the single execution
Base automatically changed from do-block to master March 23, 2023 12:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants