FreshMarker Variable Scope Revised

You see
You feel
You know
React
You’re waiting
You’re waiting

Change

Change – Killing Joke

The blog posts on the FreshMarker template engine pursue several goals. On the one hand, the posts should of course entertain their readers. On the other hand, they also serve me to focus and document my ideas on various aspects of the implementation. One very interesting topic in this post is the management of variables within the Template Engine.

The template engine cannot deny the source of inspiration, various concepts are too similar to another template engine. There are also some similarities in the variables, but there are also various differences. In FreshMarker, variables can be defined with the Var Directive and their values can be changed with the Set Directive. In FreshMarker there are no differences between global and local variables and there are no namespaces. A variable exists in its scope, which extends from the position of its definition to the end of its variable context. A variable context spans within Macro, List and Conditional Directives and of course there is an outer variable context, across the entire document.

Dhe following template represents the FizzBuzz function. This function outputs Fizz or Buzz for numbers that are divisible by 5 or 7. FizzBuzz is output for numbers that are divisible by 5 and 7 and the value is output for all other numbers.

FizzBuzz Test
<#var result=''>
<#list [1, 5, 7, 13, 15, 21, 35] as number>
  <#if number % 7 == 0 && number % 5 == 0>
    <#set result='FizzBuzz'>
  <#elseif number % 5 == 0>
    <#set result='Fizz'>
  <#elseif number % 7 == 0>
    <#set result='Buzz'>
  <#else>
    <#set result=number>
  </#if>
${result}
</#list>                
FizzBuzz Test
1
Fizz
Buzz
13
Fizz
Buzz
FizzBuzz

The template only checks the numbers 1,5,7,13,15,21 and 35 so that the output on the left-hand side is not unnecessarily long. The template itself is unnecessarily long for didactic reasons. The use of variables is only required for this article.

In the second line, the result variable is defined and then set to a new value, depending on the numerical value in lines 5, 7, 9 or 11. The current value is output in line 13. The previous Freshmarker implementation used various variable contexts for this.

The variable contexts are color-coded on the left-hand side. Red is the variable context for the entire template, green is the variable context in the List directive and blue is the four variable contexts in the blocks of the If directive. In total, there are 6 variable contexts that are created for accessing the result variable.

The nested variable contexts are necessary if, for example, a result variable is created at the beginning of the List Directive or at the beginning of a block of the If Directive. In this case, setting a value to the result variable may only change the value of the innermost variable with the name result. The outer variable with the same name result may not be modified.

In this example, however, only a single variable context is required, as shown on the right. In fact, 6 variable contexts are also created in the previous FreshMarker version if the FizzBuzz template does not use any variables at all.

To change the way variable contexts are created, two things need to be implemented. Firstly, the previously responsible directives may no longer create variable contexts. This must now be done by the VariableFragment when defining a variable. Secondly, the variable context must be removed when the processing of the surrounding directive is completed.

The second part of the implementation is done quite simply. So far, directives that span a variable context have a variant of the following process method.

@Override
public void process(ProcessContext context) {
  Environment environment = context.getEnvironment();
  try {
    context.setEnvironment(new VariableEnvironment(environment));
    content.process(context);
  } finally {
    context.setEnvironment(environment);
  }
}

The current Environment is replaced by a VariableEnvironment that takes care of the variables in this directive. Requests for unknown variables are forwarded to the previous environment and processed there. At the end, the VariableEnvironment is replaced by the original Environment in the finally block.

If no VariableEnvironment is to be created at this point, we leave this line out.

@Override
public void process(ProcessContext context) {
  Environment environment = context.getEnvironment();
  try {
    content.process(context);
  } finally {
    context.setEnvironment(environment);
  }
}

The finally block remains, as this removes Environment instances created in the meantime.

The first part of the implementation is not so easy. When defining a variable in the VariableFragment, the system has so far checked whether a variable with the name does not yet exist and then creates the variable.

private void handleVar(ProcessContext context) {
  Environment environment = context.getEnvironment();
  if (environment.checkVariable(name)) {
    throw new ProcessException("variable " + name + " must not exist", node);
  }
  environment.createVariable(name, expression.evaluateToObject(context));
}

A VariableEnvironment cannot simply be created here because a VariableEnvironment may already have been created by a previous Var Directive. In this case, the second variable must not create a new scope, but must use the variable context of the first. In the other case, the template snippet <#var result=1><#var result=1> would not generate an error.

The problem can be solved with an init field in the VariableFragment, which is set for the the first instance that occurs in a block.

private void handleVar(ProcessContext context) {
  Environment environment = context.getEnvironment();
  if (init.get()) {
    environment = new VariableEnvironment(environment);
    context.setEnvironment(environment);
  }
  if (environment.checkVariable(name)) {
    throw new ProcessException("variable " + name + " must not exist", node);
  }
  environment.createVariable(name, expression.evaluateToObject(context));
}

The field can then be used in the handleVar method to create a VariableEnvironment. Now only the init field needs to be set in the VariableFragment and the implementation of the feature is complete.

In fact, the implementation was finished at this point. But something struck me when I was formulating the previous paragraph.

The optimize method is used in the directives to optimize blocks. Where the list of Fragment instances is empty, an empty Fragment was inserted with ConstantFragment.EMPTY; if there is a single Fragment in the list, this element was returned directly. If there are several fragments in the list, these are inserted into a BlockFragment and this is returned.

public static Fragment optimizeWithVariableContext(List<Fragment> fragments) {
  return switch (fragments.size()) {
    case 0 -> ConstantFragment.EMPTY;
    case 1 -> activateVariableContext(fragments).getFirst();
    default -> new BlockFragment(activateVariableContext(fragments));
  };
}

public static List<Fragment> activateVariableContext(List<Fragment> input) {
  input.stream().filter(VarVariableFragment.class::isInstance).map(VarVariableFragment.class::cast)
      .findFirst().ifPresent(fragment -> fragment.init().set(true));
  return input;
}

The modified optimize method optimizeWithVariableContext shown here should be used at the points where the first VariableFragment in the list should have the init field set.

However, a simpler solution can be chosen here. If the list of Fragment instances contains a variable definition, then a new VariableBlockFragment can be used instead of a BlockFragment. This creates a VariableEnvironment at the start of a block and removes it again at the end. This eliminates the logic for handling the VariableEnvironment in all other fragments.

public static Fragment optimizeWithVariableContext(List<Fragment> fragments) {
  boolean containsVar = isContainsVar(fragments);
  return switch (fragments.size()) {
    case 0 -> ConstantFragment.EMPTY;
    case 1 -> containsVar ? ConstantFragment.EMPTY : fragments.getFirst();
    default -> containsVar ? new VariableBlockFragment(fragments) : new BlockFragment(fragments);
  };
}

The modified optimizeWithVariableContext contains a further optimization. If the list contains only one element and this is a variable definition, it is discarded as the variable can never be used in the block. If there is more than one element, either a VariableBlockFragment or a BlockFragment is returned, depending on the containsVar flag.

This means that the use of variables in the new FreshMarker version 1.9.0 has been revised not once but twice.

Leave a Comment