Phase 2: schema-aware typed compilation in FreshMarker

The previous post looked at the Stage 1 (untyped) compiler and finished with an honest observation: Stage 1 removes fragment-dispatch overhead but still evaluates every expression through TemplateObject. This third and final post explains how the Stage 2 compiler uses a TemplateTypeSchema to emit raw Java that talks directly to your data model — and how that unlocks the ~2.5× AOT speedup from the benchmark chart in the first post.

Everything below refers to FreshMarker 3.0.0. The compiler is experimental; the code shape of the generated class is not part of the public API.

The idea in one sentence

Stage 2 replaces every expr_N.evaluate(ctx, TemplateXxx.class).getValue() in the generated method with a plain Java expression like (String) dataModel.get("name".toUpperCase(ctx.getLocale()). To do that, the compiler needs to know the Java type of every path in your data model. Which is exactly what TemplateTypeSchema provides.

TemplateTypeSchema in five minutes

TemplateTypeSchema maps dotted paths to Java classes:

TemplateTypeSchema schema = TemplateTypeSchema.builder()
    .field("name",   SchemaType.STRING)
    .field("age",    SchemaType.INTEGER)
    .field("active", SchemaType.BOOLEAN)
    .object("address")                       // nested Map<String, Object>
        .field("city", SchemaType.STRING)
        .field("zip",  SchemaType.STRING)
    .end()
    .list("items")                           // list with typed elements
        .field("name",  SchemaType.STRING)
        .field("price", SchemaType.DOUBLE)
    .end()
    .build();

The wildcard .list(…​) step causes the compiler to store the child schema under items.* internally, so items[0].price and items[999].price resolve to the same Double.class.

SchemaType is not an enum but a container of Class<?> constants. That means the schema is extensible: any Java class works, including your own domain types. The predefined constants cover STRING, BOOLEAN, INTEGER, LONG, DOUBLE, FLOAT, BYTE, SHORT, CHARACTER, BIG_DECIMAL, BIG_INTEGER, LOCAL_DATE, LOCAL_TIME, LOCAL_DATE_TIME, ZONED_DATE_TIME, INSTANT, DURATION, PERIOD, UUID, OBJECT and LIST. Concrete List/Map implementations (e.g. ArrayListLinkedHashMap) are recognised automatically.

Beans and records

If a value is a real Java bean or record, tell the schema so it can emit typed field accesses:

TemplateTypeSchema schema = TemplateTypeSchema.builder()
    .beanList("items", "com.example.Stock")   // bean-style accessors (getX / isX)
        .field("symbol", SchemaType.STRING)
        .field("price",  SchemaType.DOUBLE)
    .end()
    .build();

// or, for a Java record:
TemplateTypeSchema recordSchema = TemplateTypeSchema.builder()
    .recordList("items", "com.example.StockRecord")
        .field("symbol", SchemaType.STRING)
        .field("price",  SchemaType.DOUBLE)
    .end()
    .build();

Internally these become BeanMapping(className, AccessorStyle.BEAN|RECORD) entries. The generator uses them to pick between ((com.example.Stock) item_0).getSymbol() and ((com.example.StockRecord) item_0).symbol().

The schema file format

The CLI reads the exact same information from a plain text file, one line per path:

# src/test/resources/quarterly-report.schema
reportDate         localdate
department         string
analyst            string
quarter            integer
products           list
products.*         record:org.freshmarker.core.compiler.QuarterlyReportTemplateTest$Product
products.*.name    string
products.*.revenue double
products.*.status  string
products.*.notes   string
safeValue          string
multiplier         integer

TemplateTypeSchema.parse(List<String>)  turns those lines into the same schema you would have built with the fluent API.

The compile pipeline

TypedJavaSourceCodeGenerator is the largest class in the compiler package. It extends AbstractJavaSourceCodeGenerator and layers three main pieces on top.

1. TypeInferenceVisitor

Before generating any code, an inner TypeInferenceVisitor walks the whole fragment tree and fills an IdentityHashMap<TemplateObject, Class<?>> (the typeCache). It propagates types through:

  • variables and dot access via the schema and the current scope stack,
  • operators using the same numeric-widening rules as the interpreter,
  • every built-in registered as a CompiledBuiltIn, via the returnType recorded in CompiledBuiltInRegister.BuiltInRecord,
  • TemplateMarkup and TemplateBuiltInVariable.

The identity map ensures every expression node has a single cached type by the time code generation starts, so the resolver never re-computes anything.

2. RawExpressionResolver

RawExpressionResolver converts a TemplateObject subtree into a Java expression string. The rules are straightforward once the types are known:

  • TemplateVariable("name") (top-level) → (String) dataModel.get("name") with the cast picked from the schema.
  • TemplateDotKey(parent, "price") → resolve parent recursively; if the parent has a bean or record mapping, emit ((com.example.Stock)parent).getPrice() for a bean or ((com.example.Stock)parent).price() for a record. Otherwise emit (Double) ((Map<String, Object>) parent).get("price").
  • TemplateOperation(left, +, right) → MathHelper.emitAddition(leftCode, leftType, rightCode, rightType) which handles numeric widening (BigDecimal > BigInteger > Double > Float > Long > Integer > Short > Byte).
  • TemplateBuiltIn(expr, "upper_case") → look up the compiled built-in for the resolved type of expr in the CompiledBuiltInRepository, and hand it the raw expression string to wrap.

Whenever the resolver hits something it does not know how to lower (a lambda, a macro call, an unsupported built-in, a mixed-type arithmetic operation), it just calls captureExpression(…​) from the base class and produces the interpreter fallback: expr_N.evaluate(ctx, TemplateXxx.class).getValue(). Nothing breaks but you just miss one optimisation point.

3. PathResolver and the scope stacks

Loops introduce local Java variables (item_0product_1, …) and simultaneously push a schema path onto a Deque<Map<String, String>> pathStack. When the body of <#list products as p> references ${p.revenue}, the resolver:

  1. Looks up p in scopeStack and finds it maps to the Java variable product_0.
  2. Looks up p in pathStack and finds it maps to the schema path products.*.
  3. Reads schema.get("products.*.revenue") wich results in Double.class.
  4. Consults schema.getBeanMapping("products.*"). Which, if present, emits ((com.example.Product) product_0).getRevenue(); otherwise the plain Map access.

Nested loops just push another frame onto both stacks.

What the generated code looks like

The following snippets follow the current 3.0.0 shape emitted by TypedJavaSourceCodeGenerator.

${name} with name: STRING in the schema

CompiledMarkup.markup(w, dataModel.get("name"), ctx);

CompiledMarkup.markup(Writer, Object, ProcessContext) is the runtime bridge that looks up the correct formatter and honours the current output-format escaper — so ${amount} still respects a <#setting number_format='…​'>.

${name?upper_case}

w.write(CompiledStringBuiltIns.upperCase((String) dataModel.get("name"), ctx.getLocale()));

<#if active> with active: BOOLEAN

if ((Boolean) dataModel.get("active")) {
    // ...
}

<#list items as item> with a plain typed list

for (Object item_0 : (List<?>) dataModel.get("items")) {
    // body: item_0 is used where the interpreter would have used the environment variable "item"
}

<#list items as item> with element item mapped to a bean com.example.Stock

for (Object item_0 : (List<?>) dataModel.get("items")) {
    // ${item.symbol}
    w.write(((com.example.Stock) item_0).getSymbol());
}

<#list items as item> — element declared as SchemaType.OBJECT

for (Object item_0 : (List<?>) dataModel.get("items")) {
    Map<String, Object> item_0_map = (Map<String, Object>) item_0;
    // ...
}

Compare this with Stage 1’s captured expr_N.evaluate(ctx, TemplateXxx.class).getValue() calls: there is no TemplateObject in the hot path anymore. The JIT sees ordinary field access, an ordinary cast, and an ordinary method call. The exact patterns HotSpot loves to inline.

The compiled built-in SPI

Built-ins are lowered through a registry rather than a switch. The functional interface is:

@FunctionalInterface
public interface CompiledBuiltIn {
    String generateCode(String rawExpression, String contextVar, List<String> parameters);
}

Implementations return a Java expression string to embed in the generated code. Each entry is stored in a CompiledBuiltInRegister as BuiltInRecord(Class<?> returnType, CompiledBuiltIn value) — the returnType is what TypeInferenceVisitor uses to propagate the resulting type upwards.

CompiledBuiltInRepository walks the type hierarchy (superclass, then interfaces) when looking up type and name, so a built-in registered against Number.class matches for IntegerLongBigDecimal, and so on.

The standard built-ins live in a handful of provider classes registered via META-INF/services/org.freshmarker.api.extension.Extension.

Adding your own is a two-liner via CompiledBuiltInProvider:

configuration.register((CompiledBuiltInProvider) () -> {
    CompiledBuiltInRegister register = new CompiledBuiltInRegister();
    register.add(SchemaType.STRING, "my_builtin", (raw, ctx, params) ->
            "com.example.MyUtils.myBuiltin((String) " + raw + ")");
    return register;
});

CompiledMarkup: keeping formatters and output-format alive

Removing TemplateObject from the hot path threatens two runtime concerns:

  • Formatters installed via Configuration#registerFormatter or <#setting>.
  • Output-format escaping stacked via <#outputformat>.

CompiledMarkup.markup(Writer w, Object value, ProcessContext ctx) is the small runtime helper that takes any raw Java value, looks up the correct formatter for its class, applies the top-of-stack output-format escaper, and writes the result. It is called for every typed interpolation, which is why compiled output remains byte-for-byte identical to interpreted output.

Fallback semantics

Whenever RawExpressionResolver cannot lower a subtree (e.g. a lambda passed to ?filter, a <@lib.macro/> call, …) it falls back to a captured TemplateObject or Fragment. The generated code still runs; the affected expression is just evaluated by the interpreter.

Practically that means:

  • You can add a schema to a template that uses macros; only the macros pay the interpreter cost.
  • You can extend the schema incrementally — first the top-level fields, then the important lists, then the bean mappings — and each addition unlocks more optimisation.

With Stage 1 removing dispatch overhead and Stage 2 removing boxing, FreshMarker 3.0.0 gives you a smooth path from “turn compilation on and see what happens” to “a fully AOT-compiled, schema-driven template class checked in as normal Java source”. Without ever leaving the familiar Configuration/Template/ProcessContext API.

Leave a Comment