“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.