FreshMarker Switch On Directive

“The creative process is not controlled by a switch you can simply turn on or off; it’s with you all the time.”

Alvin Ailey

Nothing is so good that it can’t be reworked. The switch directive in FreshMarker differed from the FreeMarker variant quite early on. FreshMarker‘s switch directive does not have a fall-through mechanism. This mechanism is a legacy of the C language family, whose switch statements with break also found their way into the template syntax. Since the end of 2024, FreeMarker now also has a new switch directive. How can this be incorporated into FreshMarker?

Before we look at the new switch directive from FreeMarker, let’s recap the fall-through mechanism. The following example shows a classic switch statement in Java.

switch (input) {
  case 1: 
    result = "1"; 
    break;
  case 2: 
    result = "2"; 
    break;
}

If the variable input is 1, the value "1" is written in the variable result, if the variable input is 2, then the value "2" is written. The respective case ends with the break statement. If a break statement is missing, we experience a fall-through.

switch (value) {
  case 1: 
    result = "1"; 
  case 2: 
    result = "2"; 
    break;
}

In this example, the value "1" is also written to result in the first case, but the case does not end and the value in result is overwritten with "2". Although a fall-through simplifies some code, a missing break is a difficult source of error to find. For this reason, fall-throughs are always a warning in code analyses. One of the reasons for not allowing the fall-through mechanism in FreshMarker.

In the meantime, the switch expression exists in Java from which the break statement has been banned.

result = switch (value) {
  case 1 -> "1"; 
  case 2 -> "2";
  default -> null;
};

Its syntax is more compact and elegant than that of the switch statement and instead of a confusing fall-through, several values can be specified for a case.

result = switch (value) {
  case 1, 2 -> "1"; 
  default -> null;
};

At this point, we return to the new switch directive, whose syntax is based on the switch expression.

<#switch color>
<#on "black">#FFFFFF
<#on "white">#0000
<#on "light-gray", "gray", "dark-gray>#808080
<#default>${color}
</#switch>

Instead of #case statements, #on statements with one or more values are now used. We already have a switch directive without fall-through in FreshMarker, but handling several values in one statement would be a nice addition.

As is so often the case, the expansion begins in the CongoCC grammar.

Switch #SwitchInstruction : 
    (<FTL_DIRECTIVE_OPEN1>|<FTL_DIRECTIVE_OPEN2>)
    <SWITCH> =>||
    <BLANK>
    Expression
    <CLOSE_TAG>
    [<WHITESPACE>]
    (Case|SwitchOn)*
    [
     SCAN 2 Default
    ]
    DirectiveEnd("switch")
;

SwitchOn #SwitchOnInstruction :
    (<FTL_DIRECTIVE_OPEN1>|<FTL_DIRECTIVE_OPEN2>)
    <ON>
    =>||
    <BLANK>
    Expression (<COMMA> Expression)* <CLOSE_TAG> Block
;

In addition to Case, SwitchOn nodes are now also permitted in the Switch. The SwitchOn is processed like Case and Switch in the SwitchFragmentBuilder. The grammar will also accept mixed switch directives, as we probably do not want this, this will be checked subsequently.

@Override
public SwitchFragment visit(SwitchOnInstruction ftl, SwitchFragment input) {
  int blockIndex = ftl.indexOf(ftl.firstChildOfType(TokenType.CLOSE_TAG)) + 1;
  List<TemplateObject> expressions = new LinkedList<>();
  for (int i = 3; i < blockIndex - 1; i +=2) {
    TemplateObject onExpression = ftl.get(i).accept(InterpolationBuilder.INSTANCE, null);
    expressions.add(onExpression);
  }
  checkMissingBlock(blockIndex, ftl);
  Node block = ftl.get(blockIndex);
  List<Fragment> fragments = block.accept(fragmentBuilder, new ArrayList<>());
  Fragment caseBlock = Fragments.optimize(fragments);
  expressions.stream().map(expression -> new ConditionalFragment(expression, caseBlock, ftl)).forEach(input::addFragment);
  return input;
}

The processing of Case and SwitchOn differs only in that several expressions are collected in the SwitchOn and a ConditionalFragment is generated for each one. The Case only knows one expression and therefore only one ConditionalFragment is generated.

At this point there is one more peculiarity of FreshMarker to mention, which now allows a little more than expected.

<#switch color>
<#on "black">#FFFFFF
<#on "white", 0>#0000
<#on "light-gray", "gray", "dark-gray">#808080
<#on foreground>foreground
<#default>${color}
</#switch>

The evaluation in FreshMarker accepts Case and now also SwitchOn statements with different types in one switch directive. With the next version of FreshMarker, the switch directive will again support expressions in the Case and SwithOn statements. This has been dropped in the meantime, but without special optimizations in the directive, there is no reason to prohibit this.

If you don’t like this, you can restrict the feature using SwitchDirectiveFeature.ALLOW_ONLY_CONSTANT_ONS or SwitchDirectiveFeature.ALLOW_ONLY_CONSTANT_CASES. To do this, the type of the expressions is checked in the visit method. If the feature is set, then only primitive types, i.e. constants, may be used.

@Override
public SwitchFragment visit(SwitchOnInstruction ftl, SwitchFragment input) {
  logger.debug("{} {}", ftl.size(), ftl.children());
  int blockIndex = ftl.indexOf(ftl.firstChildOfType(TokenType.CLOSE_TAG)) + 1;
  List<TemplateObject> expressions = new LinkedList<>();
  for (int i = 3; i < blockIndex - 1; i +=2) {
    TemplateObject onExpression = ftl.get(i).accept(InterpolationBuilder.INSTANCE, null);
    if (featureSet.isEnabled(SwitchDirectiveFeature.ALLOW_ONLY_CONSTANT_ONS) && !onExpression.isPrimitive()) {
      throw new ParsingException("only constant expression allowed", ftl.get(i));
    }
    expressions.add(onExpression);
  }
  checkMissingBlock(blockIndex, ftl);
  Node block = ftl.get(blockIndex);
  List<Fragment> fragments = block.accept(fragmentBuilder, new ArrayList<>());
  Fragment caseBlock = Fragments.optimize(fragments);
  logger.debug("on: {} {}", block, caseBlock);
  expressions.stream().map(expression -> new ConditionalFragment(expression, caseBlock, ftl)).forEach(input::addFragment);
  return input;
}

For users who are not comfortable with a switch directive with different types of constants, there are the SwitchDirectiveFeature.ALLOW_ONLY_EQUAL_TYPE_ONS and SwitchDirectiveFeature.ALLOW_ONLY_EQUAL_TYPE_CASES features.

@Override
public SwitchFragment visit(SwitchInstruction ftl, SwitchFragment input) {
  Node expression = ftl.get(3);
  TemplateObject switchExpression = expression.accept(InterpolationBuilder.INSTANCE, null);
  SwitchFragment switchFragment = new SwitchFragment(switchExpression, expression);
  List<CaseInstruction> caseParts = ftl.childrenOfType(CaseInstruction.class);
  List<SwitchOnInstruction> switchOnParts = ftl.childrenOfType(SwitchOnInstruction.class);
  if (!caseParts.isEmpty() && !switchOnParts.isEmpty()) {
    throw new ParsingException("switch directive contains on and case", ftl);
  }
  if (!caseParts.isEmpty()) {
    handle(caseParts, switchFragment, ftl, featureSet.isEnabled(ALLOW_ONLY_CONSTANT_CASES) && featureSet.isEnabled(ALLOW_ONLY_EQUAL_TYPE_CASES));
  }
  if (!switchOnParts.isEmpty()) {
    handle(switchOnParts, switchFragment, ftl, featureSet.isEnabled(ALLOW_ONLY_CONSTANT_ONS) && featureSet.isEnabled(ALLOW_ONLY_EQUAL_TYPE_ONS));
  }
  DefaultInstruction defaultPart = ftl.firstChildOfType(DefaultInstruction.class);
  if (defaultPart != null) {
    defaultPart.accept(this, switchFragment);
  }
  return switchFragment;
}

private <T extends BaseNode> void handle(List<T> switchParts, SwitchFragment switchFragment, SwitchInstruction ftl, boolean isOnlyEqualTypes) {
  switchParts.forEach(part -> part.accept(this, switchFragment));
  if (isOnlyEqualTypes && Set.copyOf(switchFragment.getConditionals()).size() > 1) {
    throw new ParsingException("constants with different types", ftl);
  }
}

In the handle method, the Case and SwitchOn statements are processed and then, if necessary, the types of the expressions are checked. To do this, the types are returned as a List and inserted into a Set. If the Set contains more than one element, an ParsingException is thrown.

This blog post shows how quickly a new directive can be implemented in FreshMarker. The entire feature was created within an hour, including documentation. The rapid implementation was facilitated in particular by low technical debt, a narrowly defined feature set and the CongoCC parser generator.

Leave a Comment