FreshMarker has always optimised constant expressions during template parsing. The expression 2 + 3 becomes 5 before the first process(…) call is ever made. That optimisation, however, had a hard boundary: anything involving the ~~ operator, registered formatters, or context-free built-ins was left untouched because the parser had no access to the configuration that governs them. This post describes the ParseContext, a new concept that hands build-time knowledge to the template builders and what that unlocks.
The Gap between Parsing and Processing
When FreshMarker builds a template, it walks the parse tree produced by the CongoCC grammar and turns each node into a fragment of the internal representation. Expressions go through InterpolationBuilder, which already performed one class of optimisation: constant folding. If both sides of an arithmetic, relational, equality, or boolean expression are literal primitives, the result is computed immediately and the entire subtree is replaced by a single constant:
${ 2 + 3 } <#-- becomes TemplateNumber(5) at parse time -->
${ 'a' == 'b' } <#-- becomes TemplateBoolean.FALSE at parse time -->
${ true && false } <#-- becomes TemplateBoolean.FALSE at parse time -->
This works because the four operations in question do not need any runtime information. The implementation simply passes null as the ProcessContext argument to evaluateToObject, and because primitive TemplateObject implementations never dereference that argument, no NullPointerException occurs.
However, two categories of expressions were explicitly excluded from this optimisation:
- The
~~operator needscontext.getLocale()andcontext.getFormatter(type)to format non-string primitives. Without those, callingevaluateToObject(null)would throw immediately. - Built-in functions on literal primitives like
. These are stored as'hello'?trim,42?abs,'text'?lengthHookedBuiltInornodes even when the input is a compile-time constant, because the builder had no way of distinguishing context-free built-ins from locale-sensitive or output-format-sensitive ones.TemplateBuiltIn
Both categories share the same root cause: the template builders received StaticContext (containing built-ins, registered formatters, extension registry) but had no access to the build-time defaults — locale, output format — that are known to DefaultTemplateBuilder and that a ProcessContext would normally carry.
Introducing ParseContext
ParseContext is a new class in org.freshmarker.core that bridges the gap. It wraps a StaticContext and adds the two pieces of information that were missing:
public final class ParseContext {
private final StaticContext staticContext;
private final FeatureSet featureSet;
private final Locale defaultLocale; // from DefaultTemplateBuilder
private final OutputFormat defaultOutputFormat; // from DefaultTemplateBuilder
// ... delegating accessors for StaticContext fields ...
public ProcessContext minimalProcessContext() { ... }
}
The design is deliberately minimal:
- No runtime state. There is no
, noWriter, no variable stack. AEnvironmentParseContextis safe to share across builders and across threads. - Replaces StaticContext as the parameter type visible to
FragmentBuilder,InterpolationBuilder,MacroBuilder, andImportBuilder. The underlyingStaticContextis still reachable viaparseContext.staticContext()for the places that need it (e.g. when constructing the finalTemplateobject). - One added capability:
minimalProcessContext(). A lightweightbacked by the build-time locale and formatters. It supportsProcessContextgetLocale(),getFormatter(type),getOutputFormat(), and little else. It is the key that unlocks the two new constant-folding improvements described below.
ParseContext is constructed once per getTemplate(…) call in DefaultTemplateBuilder, immediately after StaticContext and LocalContext are assembled:
// DefaultTemplateBuilder.getTemplate(TemplateSource source)
StaticContext templateContext = new StaticContext(extensionRegistry, combinedFormatters);
// ...
LocalContext localContext = new LocalContext(locale, outputFormat, ...);
SimpleFeatureSet featureSetCopy = new SimpleFeatureSet(featureSet);
ParseContext parseContext = new ParseContext(templateContext, featureSetCopy, locale, outputFormat);
List<Fragment> fragments = root.accept(
new FragmentBuilder(buildContext, null, featureSetCopy, 0, parseContext, new TemplateDictionary()),
new ArrayList<>());
What Changes for ~~
Previously, InterpolationBuilder had to skip ~~ in its constant-folding loop even when both operands were literals:
// Old code
if (operation.op() != Operator.FORMATTED_CONCAT && result.isPrimitive() && second.isPrimitive()) {
result = operation.evaluateToObject(null); // null: only safe without FORMATTED_CONCAT
} else {
result = operation; // FORMATTED_CONCAT always deferred
}
With ParseContext, the carve-out disappears. minimalProcessContext() provides the locale and formatter that ~~ needs, so it can be evaluated immediately for literal operands:
// New code
if (result.isPrimitive() && second.isPrimitive()) {
try {
result = operation.evaluateToObject(parseContext.minimalProcessContext());
} catch (ProcessException e) {
throw new ParsingException(e.getMessage(), expression);
}
} else {
result = operation;
}
A concrete example — assuming a NumberFormatter("#,0.") is registered:
${ 'Total:' ~~ 1500.5 }
Without ParseContext, this expression survived in the AST as a TemplateConcatOperation node and was evaluated on every single process(…) call. With ParseContext, the entire expression collapses to the constant TemplateString("Total: 1.500,5") (using the configured locale and number format) during getTemplate(…). The fragment produced is a ConstantFragment that just writes a fixed string.
The Locale Dependency
The operator ~~ uses the configured default locale for folding, not any locale that might be pushed at render time via <#setting locale=…>. This is the right behaviour: literal-constant expressions in a template do not depend on per-request state, so their folded values should reflect the template’s own configuration. If a template genuinely needs locale-sensitive output from a constant, it should use a non-literal expression.
What Changes for Built-in Functions on Literals
The second improvement is parse-time constant folding for built-in functions applied to literal primitives.
Before this change, InterpolationBuilder.createBuiltIn(…) always created a HookedBuiltIn node, regardless of whether the input was a known constant:
// Old code — always deferred
private TemplateObject createBuiltIn(String name, ...) {
Map.Entry<Class<? extends TemplateObject>, BuiltIn> entry =
templateContext.builtIns().byName(name);
if (entry != null) {
return new HookedBuiltIn(expression, entry.getKey(), name, entry.getValue(), ...);
}
return new TemplateBuiltIn(name, expression, ...);
}
The new version has two additional steps after the entry lookup:
// New code
Map.Entry<Class<? extends TemplateObject>, BuiltIn> builtIn =
parseContext.builtIns().byName(builtInName);
if (builtIn != null) {
TemplateObject expression = templateObjectAndNode.templateObject();
// 1. Parse-time type check
if (expression.isPrimitive() && !builtIn.getKey().isInstance(expression)) {
throw new ParsingException(
"built-in '?" + builtInName + "' requires " + builtIn.getKey().getSimpleName()
+ " but expression is " + expression.getClass().getSimpleName(),
templateObjectAndNode.node());
}
// 2. Parse-time constant folding
if (expression.isPrimitive()
&& builtIn.getKey().isInstance(expression)
&& parameter.stream().allMatch(TemplateObject::isPrimitive)) {
try {
TemplateObject folded = builtIn.getValue().apply(expression, parameter, null);
if (folded != null && folded.isPrimitive()
&& !(folded instanceof TemplateStringMarkup)) {
return folded; // constant: no HookedBuiltIn needed
}
} catch (Exception ignored) {
// built-in needs runtime context → fall through to HookedBuiltIn
}
}
return new HookedBuiltIn(expression, builtIn.getKey(), builtInName,
builtIn.getValue(), parameter, ...);
}
How the Folding Heuristic Works
The folding attempt uses null as the ProcessContext argument to BuiltIn.apply(…). This is the same trick the existing arithmetic folding used, but now applied to built-ins.
A built-in that is truly context-free, which never dereferences the context argument, will return a result without incident. A built-in that needs the locale (e.g. ?upper_case), the resource bundle (e.g. ?i18n), the output format (e.g. ?esc), or any other runtime state will throw a the moment it tries to call NullPointerExceptioncontext.getLocale() or similar. The catch (Exception ignored) turns that signal into not foldable and falls through to the normal HookedBuiltIn path.
Two additional guards keep the folding conservative:
- TemplateStringMarkup is excluded from the result check. Built-ins like
?no_escthat produce markup nodes depend on the current output format, which can vary per render call (e.g. via<#outputformat>). Accepting aTemplateStringMarkupas a folded constant would bind the output-format escaping decision to the build-time default. That would silently produce wrong output for templates that use dynamic output format switching. - All parameters must be primitive. If a parameter is a model variable or a complex expression, the call cannot be folded, because the parameter value is not known at parse time.
Built-ins That Fold
The following built-ins fold unconditionally when applied to a literal, because their implementations never touch the context:
| Category | Built-in | What it folds |
|---|---|---|
| String | ?trim, ?strip, ?strip_leading, ?strip_trailing | Removes surrounding whitespace |
| String | ?length | Constant integer: 'hello'?length → 5 |
| String | ?is_empty, | Constant boolean from the literal content |
| String | ?boolean | Parses / to TemplateBoolean |
| String | ?blank_to_null, ?empty_to_null, ?trim_to_null, ?strip_to_null | Returns NULL or the trimmed string |
| String | ?ends_with, ?starts_with, ?contains | Boolean result when both arguments are literals |
| Number | ?abs | Absolute value: -42?abs → 42 |
| Number | ?int, ?long, ?double, ?float, ?short, ?byte | Numeric type coercion on a literal or immediate ProcessException if out of range |
| Character | ?is_whitespace, ?is_digit, ?is_alphabetic, ?upper_case, ?lower_case | Structural character tests using Character |
Built-ins that never fold (the null context causes a NullPointerException):
| Built-in | Why it cannot fold |
|---|---|
?upper_case/?lower_case (String) | Calls context.getLocale() for locale-sensitive |
?capitalize, ?uncapitalize, ?camel_case, ?kebab_case, ?snake_case | Same: locale-sensitive transformation |
?esc, ?escape | Calls context.getOutputFormat(name) for output-format lookup |
?i18n | Calls context.getResourceBundle() for resource-bundle lookup |
?format (Number) | Calls context.getLocale() for locale-sensitive number formatting |
Lambda-based (?filter, ?map, ?count, …) | Execute a user-supplied lambda that may access model variables |
The Third Improvement: Parse-Time Type Checking
A side benefit of having typed built-in resolution at parse time is that mismatched types can now be caught immediately when the input is a literal.
Before, writing ${42?upper_case} would parse without error and only fail at render time with an UnsupportedBuiltInException. Now:
ParseException: built-in '?upper_case' requires TemplateString
but expression is TemplateNumber
at test:1:3 '${ 42?upper_case }'
The check is conservative: it only fires when InterpolationBuilder can prove at parse time that the expression is a specific primitive type. Variable references, method calls, and non-primitive expressions still receive the error at render time, unchanged.
What Did Not Change
- The fallback pattern is untouched. If a built-in is not registered for exactly one type (
BuiltInRepository.byName(name)returnsnull), or if folding throws, the code produces the exact sameHookedBuiltInorTemplateBuiltInnode it always did. No behaviour change occurs for any expression that is not a pure literal. - TemplateStringMarkup-returning built-ins are never folded. Output-format-sensitive built-ins like
?no_escalways pass through to a runtime node. - The ~~ folding uses the build-time locale, not a null context. The
minimalProcessContext()carries theLocaleandFormattermap from the builder configuration. The same values that would be active at render time. This is the semantically correct value for constant literal folding.
Impact on Fragment Trees
To give a sense of the practical difference, consider a template fragment with three constant expressions:
${' hello '?trim}
${-7?abs}
${'Foo:' ~~ 42}
Before this change, the fragment tree contained three InterpolationFragment nodes, each wrapping a TemplateMarkup that held a HookedBuiltIn (or TemplateConcatOperation). On every process(…) call, each expression would be re-evaluated end-to-end.
After this change, all three expressions fold to constants during getTemplate(…). The fragment tree contains three ConstantFragment nodes that write fixed strings directly. The built-in lookup, the argument evaluation, and the string formatting all happen exactly once — at build time.