This page is for contributors who want to understand or modify the engine. It traces a template from text to rendered output and points at the types that do each job.
flowchart TD
src["Template text"] --> lex
subgraph parse["DocumentParser.Parse (steps 1-3)"]
lex["1. LEX<br/>HeddleLexer (ANTLR, mode stack)"] -->|tokens| prs["2. PARSE<br/>HeddleParser - SLL, fallback LL"]
prs -->|parse tree| walk["3. WALK<br/>HeddleMainListener + ParseContext"]
prs -.->|syntax errors| errs["HeddleSyntaxErrorListener"]
end
subgraph compile["HeddleCompiler.Compile (steps 4-5)"]
cmp["4. COMPILE (HeddleCompiler)<br/>extensions + compiled accessors"] --> mem["member paths -> expression-tree delegates"]
cmp --> cs["@(...) C# -> Roslyn delegates"]
end
walk -->|"definitions, output chains"| cmp
cmp --> rt["RuntimeDocument (+ IProcessStrategy)"]
rt --> render["6. RENDER<br/>HeddleTemplate.Generate(data)<br/>ScopeRenderer -> string"]
errs -.->|collected| result["HeddleCompileResult"]
cmp -.-> result
The orchestration entry points are
DocumentParser.Parse (steps 1–3) and
HeddleTemplate.Compile → HeddleCompiler.Compile (steps 4–5).
The lexer is generated from HeddleLexer.g4 (which
imports CSharp.g4 for C# tokens). It is mode‑based:
a stack of lexer modes makes the language context‑sensitive so that the same characters mean
different things in different places (e.g. }} ends a subtemplate but is plain text at the
top level).
Modes (entered/exited via pushMode/popMode/mode):
| Mode | Entered by | Purpose |
|---|---|---|
| (default) | — | Top‑level text + directive starts (@%, @<<, @, raw). A top‑level {{ here is a plain unconnected token — it does not push SUB_BLOCK. |
SUB_BLOCK |
{{ after a call (CALL_RETURNED), inside a definition (DEF), or after an extension name (OUT_MODE) |
Subtemplate body; }} pops. |
DEF |
@% |
Definition block: <name>, : base, :: type, -> default, ( opens a prop list, %@ pops. |
DEF_PROPS |
( in DEF |
Definition prop list (v2 typed props): prop names/types between ( … ). |
IMPORT_MODE |
@<< |
Import path between {{ }}. |
OUT_MODE |
@ |
Extension name; ( opens parameter. |
CALL |
( |
Parameter tokens (ids, ., ::, :), nested (; inner @ → CS. |
CALL_RETURNED |
) |
What follows a call: : chain, @ next, {{ body, raw, etc. |
CS |
inner @ in a parameter |
Embedded C# expression; balances (/), ends at matching ). |
CS_NESTED |
nested ( in CS |
Nested parentheses inside an embedded C# expression. |
INTERP_STR / INTERP_VERBATIM_STR / INTERP_HOLE |
$"…" / $@"…" inside CS |
C# interpolated‑string literals and their { … } holes. |
The mode transitions (solid = push, dashed = pop/return to a mode):
stateDiagram-v2
state "DEFAULT" as Top
[*] --> Top
Top --> DEF: "@%"
Top --> IMPORT_MODE: "@<<"
Top --> OUT_MODE: "@"
DEF --> SUB_BLOCK: "{{"
DEF --> OUT_MODE: "-> (default output)"
DEF --> Top: "%@"
OUT_MODE --> SUB_BLOCK: "{{ (after ext name)"
SUB_BLOCK --> Top: "}}"
IMPORT_MODE --> Top: "}}"
OUT_MODE --> CALL: "("
CALL --> CS: "inner @"
CALL --> CALL_RETURNED: ")"
CS --> CALL_RETURNED: "matching )"
CALL_RETURNED --> OUT_MODE: ": (chain) or @"
CALL_RETURNED --> SUB_BLOCK: "{{ (call body)"
CALL_RETURNED --> Top: "text / raw"
Comments (@* … *@), trimmed whitespace (@\), and definition whitespace are routed to the
hidden channel so they never reach the parser but are still available for tooling (the
listener collects hidden‑channel positions as SkippedTokens). This is why comments can
appear mid‑construct — between any two tokens (e.g. inside a call) — though never inside a single
token. See the
Language Reference → lexer modes
for the author‑facing view.
The parser is generated from HeddleParser.g4. The
top‑level rule is heddle; the interesting rules are definition, outblock, chain, call,
member_expression, csharp_expression, and subtemplate.
DocumentParser controls prediction strategy:
- It first parses in SLL mode (fast). If SLL throws
ParseCanceledException(ambiguity) it falls back toLL_EXACT_AMBIG_DETECTION(full LL) viaParseDiagnosticModeand records aHeddleCompileWarningnoting the SLL failure. If SLL instead merely reported errors, it re‑parses the same way in full LL mode, but records no warning on that path. - When
TemplateOptions.ProvideLanguageFeaturesis set (editor/tooling mode), it parses directly in LL mode and produces a token list for syntax highlighting instead of optimizing for throughput.
Syntax errors are gathered by
HeddleSyntaxErrorListener into the
ParseContext, then copied onto CompileContext.CompileErrors.
A ParseTreeWalker drives
HeddleMainListener, which builds the
ParseContext: the set of definitions
(DefinitionBlock/DefinitionItem), output chains (OutputChain/OutputItem),
imports, and the raw/text spans. This is the structured representation the compiler consumes.
HeddleCompiler turns the parse context into an
execution‑ready document (RuntimeDocument):
a tree of extension instances, each wired to a compiled value accessor. The template itself
is not transpiled to C# or to IL — its structure becomes an object graph; only the value
accessors are compiled. For statically‑typed models nothing is interpreted or reflected at render
time; dynamic (dynamic/ExpandoObject) models instead bind their member paths through cached
DLR call sites (see below).
- It instantiates the right extension for each call (resolved by name from the registered
set), and calls
InitStartto thread types through the chain and compile nested subtemplates; extensions needing a second pass useCompleteInit(e.g.PartialExtension). - Member paths (
@(A.B.C)) compile to expression‑tree delegates:ModelParameterbuilds anExpression<Func<object,object>>over the resolved property chain (null‑safe for reference types) and.Compile()s it. Reflection is used only here, at compile time, to locate the properties — never at render time for statically‑typed models. (Dynamic models compile instead to DLR call sites that resolve members at render time, cached per receiver type.) - Embedded C# expressions (
@( … )) are emitted into C# source via the.tcstemplates in src/Heddle/LanguageTemplates (CSharpPreparseTemplate.tcs,CSharpClassTemplate.tcs) and compiled by Roslyn (Microsoft.CodeAnalysis.CSharp) into(model, chained, root)delegates (CompiledParameter). This is also where C# expressions are bound to the model's types; theCompileScope/CSharpContexttrack imported namespaces (@using) and the model type (@model,:: Type).CompileScope.Compile()runs that Roslyn pass. - Errors from either path are collected as
HeddleCompileErrors rather than thrown, and surfaced throughHeddleCompileResult.
The result is a RuntimeDocument exposing an IProcessStrategy (Strategy) — the
execution‑ready render tree. So "compiling a template" means two kinds of code generation
(expression‑tree delegates for member access, Roslyn delegates for embedded C#) wired into one
document — not a single whole‑template Roslyn compile.
HeddleTemplate.Generate creates a
ScopeRenderer and a root Scope,
then invokes _processStrategy.Render(scope). Extensions write through
scope.Renderer.Render(...) (the streaming path, RenderData) or return strings
(ProcessData) when a parent needs the value (e.g. inside a chain). The renderer's buffer is
size‑adaptive across calls (a per‑instance high‑water mark with ~10 % margin; the field is
length‑based on net8+ and count‑based on older targets).
Scope is a small readonly struct carrying ModelData, ChainedData, ParentModelData,
CallerData, RootData, and the Renderer; its pure transforms (Model, Parent, Chain,
…) construct the child scopes extensions hand to their subtemplates. See
Writing Custom Extensions for the author‑facing contract.
The repository's BenchmarkDotNet suite measures Heddle against four
other .NET template engines (Fluid, Scriban, DotLiquid, Handlebars.Net) — all four rendering
byte‑identical parity‑checked output over a component‑heavy composition workload — plus ASP.NET Core
Razor, which renders a larger, different page and is not under the parity assertion
([MemoryDiagnoser] enabled). In the run of 2026‑07‑11 (commit 8341bb67; AMD Ryzen 9 9950X,
.NET 10.0.9, BenchmarkDotNet 0.15.8) Heddle rendered that page in 32.50 μs / 227.86 KB — the
fastest of the six and tied‑least on allocation (within 0.3 KB of Handlebars.Net); the next engine (Fluid) took 2.0× as long and
Scriban 11.7× with 5.07× the allocation. The full render and compile‑cost tables, environment
header, and raw artifacts live in the README Performance section and
docs/benchmarks/2026-07-11. The reasons Heddle leads on the render path are
structural, not incidental:
- Execution‑ready document, not per‑call activation. Each template becomes a
RuntimeDocument/IProcessStrategywith extension instances already resolved and typed, and every value accessor pre‑compiled (expression‑tree delegates for member paths, Roslyn delegates for embedded C#) — no reflection or re‑parsing per render. Rendering a dozen components is a dozen directRenderDatacalls — it does not spin up partials, view components, section buffers, or DI scopes per component the way Razor does. The benchmark page exercises exactly this: many@area_component(...),@assets_component(...),@head_scripts(), etc. calls. - Streaming, low‑allocation output. Extensions write directly to a single
ScopeRendererwhose buffer is size‑adaptive across renders (a per‑instance high‑water mark), so a warmed‑up template stops reallocating its output buffer.listpre‑sizes fromICollection<T>.Countwhen available. - Struct
Scope, aggressive inlining. The per‑node data view is a readonlystructwith[MethodImpl(AggressiveInlining)]transforms, avoiding per‑scope heap allocation as the renderer descends into elements and subtemplates. - Composition is near‑free at run time. Splitting a page into independent reusable templates
recombined by a layout (see the benchmark's
@<<{{layout.heddle}}import +<body:body>override) renders through one pre‑built extension node per definition invocation — no per‑render lookup, activation, or buffer indirection — unlike Razor sections, whose layout/section binding adds indirection. See Language Reference → inheritance.
The trade‑off is up‑front compilation: the first compile runs ANTLR (parse), expression‑tree compilation (member accessors), and Roslyn (embedded C#), so it is not cheap — the model is "compile once, render many." In the same 2026‑07‑11 run, cold‑compiling the layout + home templates took 264.99 μs / 1,339.67 KB for Heddle versus single‑digit microseconds for the Liquid engines (Fluid 3.65 μs, Scriban 4.68 μs, DotLiquid 7.21 μs) — a cost amortized across every cached render. Compile cost is benchmarked via TemplateParseBenchmarks and the runners in src/Heddle.Performance/Runners; full table in the README.
| Path | Contents |
|---|---|
| src/Heddle | Core engine. |
Heddle/Core |
Extension base classes, InitContext. |
Heddle/Extensions |
The 24 built‑in extensions. |
Heddle/Language |
Parser host: DocumentParser, HeddleMainListener, ParseContext, OutputItem, DefinitionItem, CallParameter. |
Heddle/Runtime |
HeddleCompiler, RuntimeDocument, CompileContext, CompileScope, IExtension, TemplateResolver. |
Heddle/Data |
Scope, TemplateOptions, HeddleCompileResult, ExType, LinearList, render types. |
Heddle/Attributes |
The extension/model attributes. |
Heddle/Strings |
Fast string building (ExStringBuilder). |
Heddle/LanguageTemplates |
.tcs resources used to emit C# for Roslyn. |
| src/Heddle.Language | ANTLR grammar + generated lexer/parser + editor assets. |
| src/Heddle.Tests | xUnit tests + .heddle fixtures. |
| src/Heddle.Performance | BenchmarkDotNet benchmarks. |
The grammar lives in src/Heddle.Language:
- HeddleLexer.g4 — lexer (modes, tokens).
- HeddleParser.g4 — parser rules.
- CSharp.g4 — C# token fragments imported by the lexer.
Generated C# (checked in under generated/) is produced by ANTLR 4.13.1. To regenerate after
editing the .g4 files, run generate_cs.cmd
(requires Java; the script downloads the ANTLR 4.13.1 jar from antlr.org on first run):
java -jar "antlr-4.13.1-complete.jar" -Dlanguage=CSharp "HeddleLexer.g4" "HeddleParser.g4" -o "generated" -lib "generated" -package Heddle.Language
The project references Antlr4.Runtime.Standard 4.13.1 at run time. The js/ and
ace_build/ directories are excluded from the C# compile
(Heddle.Language.csproj).
- JavaScript parser —
generate_js.cmdproduces a JS lexer/parser underjs/from the same grammar (for in‑browser editing). - Ace editor —
ace_build/andbuild_ace.shbuild an Ace mode using the JS parser for syntax highlighting on the web. - Language features (tooling) — setting
TemplateOptions.ProvideLanguageFeaturesmakesDocumentParseremit a token list — the basis for editor classification (highlighting), error tagging, and completion / quick info for.heddlefiles. - TextMate grammar — coloring-scheme/heddle.tmLanguage.json is a portable highlighter (HTML + inline C# embedded) whose scopes mirror the Ace mode, for VS Code/Monaco/Shiki and documentation sites. See Syntax Highlighting.
For build/test/packaging mechanics, continue to Building & Testing.