Architecture
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.
High‑level pipeline
The orchestration entry points are DocumentParser.Parse (steps 1–3) and HeddleTemplate.Compile → HeddleCompiler.Compile (steps 4–5).
1. Lexing
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):
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.
2. Parsing
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.
3. Tree walking
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.
4–5. Compilation
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.
6. Rendering
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.
Performance characteristics
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.
Source layout (src/)
| 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 and generated code
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.LanguageThe 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).
Editor / tooling integrations
- 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.