ParseContext: Smarter Template Building with Build-Time Knowledge

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:

  1. The ~~ operator needs context.getLocale() and context.getFormatter(type) to format non-string primitives. Without those, calling evaluateToObject(null) would throw immediately.
  2. Built-in functions on literal primitives like 'hello'?trim42?abs'text'?length. These are stored as HookedBuiltIn or TemplateBuiltIn nodes 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.

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 Writer, no Environment, no variable stack. A ParseContext is safe to share across builders and across threads.
  • Replaces StaticContext as the parameter type visible to FragmentBuilderInterpolationBuilderMacroBuilder, and ImportBuilder. The underlying StaticContext is still reachable via parseContext.staticContext() for the places that need it (e.g. when constructing the final Template object).
  • One added capability: minimalProcessContext(). A lightweight ProcessContext backed by the build-time locale and formatters. It supports getLocale()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 NullPointerException the moment it tries to call context.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_esc that produce markup nodes depend on the current output format, which can vary per render call (e.g. via <#outputformat>). Accepting a TemplateStringMarkup as 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:

CategoryBuilt-inWhat it folds
String?trim?strip?strip_leading?strip_trailingRemoves surrounding whitespace
String?lengthConstant integer: 'hello'?length → 5
String?is_empty?is_blankConstant boolean from the literal content
String?booleanParses "true"/"false" to TemplateBoolean
String?blank_to_null?empty_to_null?trim_to_null?strip_to_nullReturns NULL or the trimmed string
String?ends_with?starts_with?containsBoolean result when both arguments are literals
Number?absAbsolute value: -42?abs → 42
Number?int?long?double?float?short?byteNumeric type coercion on a literal or immediate ProcessException if out of range
Character?is_whitespace?is_digit?is_alphabetic?upper_case?lower_caseStructural character tests using Character

Built-ins that never fold (the null context causes a NullPointerException):

Built-inWhy it cannot fold
?upper_case/?lower_case (String)Calls context.getLocale() for locale-sensitive
?capitalize?uncapitalize?camel_case?kebab_case?snake_caseSame: locale-sensitive transformation
?esc?escapeCalls context.getOutputFormat(name) for output-format lookup
?i18nCalls 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) returns null), or if folding throws, the code produces the exact same HookedBuiltIn or TemplateBuiltIn node 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_esc always pass through to a runtime node.
  • The ~~ folding uses the build-time locale, not a null context. The minimalProcessContext() carries the Locale and Formatter map 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.

Leave a Comment