Pure C# template engine — flow control inside interpolated strings, no DSL required.
Waffle embeds For, ForEach, If/Elif/Else, and End commands directly into C# interpolated string literals
via a customized
InterpolatedStringHandler.
Templates are ordinary C# — you get full IDE support, compile-time checking, and top-tier performance with no external
parser or DSL.
Building output strings that contain loops and conditionals is unavoidable in code generation, but every existing approach forces a trade-off:
| Approach | Readability | IDE Support | Performance |
|---|---|---|---|
| StringBuilder | △ | ◎ | ◎ |
| T4 | ◎ | ○ | △ |
| T4 (preprocessed) | △ | ○ | ◎ |
| Scriban | ◎ | △ | △ |
| Waffle | ◎ | ◎ | ◎ |
T4 (preprocessed) = code generated by t4 -c, run directly as C#.
Waffle resolves all three by using C#'s interpolated-string machinery as the template parser. This matters most inside Incremental Source Generators, where build-time overhead and IDE responsiveness are non-negotiable.
The table above focuses on readability, IDE support, and performance. Other factors may influence your choice of template engine (T4, Scriban, Razor-based engines, Liquid-compatible libraries, CodegenCS, etc.):
- Separate project required — Waffle templates are C# code that must live in a dedicated project (a Source Generator or a console app). Unlike T4, you cannot drop a template file directly into the target project and generate output in place.
- Minimal feature surface — Waffle intentionally provides only the primitives needed for code generation (
For,ForEach,If,Let, etc.). It does not offer Liquid-compatible syntax, LINQ-style query operators, or the rich built-in filters found in more general-purpose template engines like Scriban.
Waffle is best suited for scenarios where templates run inside an Incremental Source Generator or a build-time tool and you need maximum performance with full IDE support. If your priorities differ — e.g. zero-setup convenience, a feature-rich DSL, or Razor/Liquid compatibility — another engine may be a better fit.
| Package | Description |
|---|---|
| Waffle.Core | Core engine: WaffleSyntax API, TemplateInterpreter, block AST, lazy-resolution pipeline |
| Waffle.ModelProxy | Incremental Source Generator — generates {Type}Proxy wrappers so model members are accessible as Waffle tokens without .To() lambdas |
| Waffle.Bakery | Batch-execution framework for running multiple templates and collecting their outputs |
| Waffle.Analyzer | Roslyn Analyzer — detects template syntax errors (unmatched blocks, out-of-scope variables, etc.) at compile time |
For basic template rendering, add Waffle.Core:
<ItemGroup>
<PackageReference Include="Waffle.Core" Version="1.x"/>
</ItemGroup>To also access model members cleanly inside loop bodies, add Waffle.ModelProxy as a source generator:
<ItemGroup>
<PackageReference Include="Waffle.ModelProxy" Version="1.x">
<ReferenceOutputAssembly>false</ReferenceOutputAssembly>
<OutputItemType>Analyzer</OutputItemType>
</PackageReference>
</ItemGroup>To catch template syntax errors (unmatched End, out-of-scope loop variables, misplaced Elif/Else, etc.) at compile time, add Waffle.Analyzer:
<ItemGroup>
<PackageReference Include="Waffle.Analyzer" Version="1.x">
<ReferenceOutputAssembly>false</ReferenceOutputAssembly>
<OutputItemType>Analyzer</OutputItemType>
</PackageReference>
</ItemGroup>Import the static API and call Render:
using static Waffle.WaffleSyntax;
var result = Render($$"""
public class MyClass
{
{{For(0, 3, out var i)}}
public int Value{{i}} { get; set; }
{{End}}
}
""");
Console.WriteLine(result);Output:
public class MyClass
{
public int Value0 { get; set; }
public int Value1 { get; set; }
public int Value2 { get; set; }
}For, ForEach, If, Elif, Else, End and more are all standard C# expressions that fit naturally into any
interpolated string literal.
See the Syntax Reference for the full API.
When iterating over a collection with ForEach, the loop variable is a deferred placeholder —
its value isn't known until the template is rendered. Normally this means you can't access members
like .Type or .Name with plain property syntax.
Waffle.ModelProxy solves this: annotate your model types with
[ModelProxy], call .AsProxy(), and you can access members directly — just like regular C# objects.
The source generator takes care of the deferred-evaluation wiring behind the scenes.
using static Waffle.WaffleSyntax;
var m = new StructModel("ReadOnlyIntVector3", new[]
{
new PropertyModel("int", "X", "x"),
new PropertyModel("int", "Y", "y"),
new PropertyModel("int", "Z", "z"),
}).AsProxy();
var result = Render($$"""
public readonly partial struct {{m.Name}}
{
{{ForEach(m.Properties, out var p)}}
public readonly {{p.Type}} {{p.Name}};
{{End}}
{{If(m.Properties.Count > 0)}}
public {{m.Name}}(
{{ForEach(m.Properties, out p, out _, out var h)}}
{{p.Type}} {{p.PrivateName}}{{h.CommaOrLastEmpty}}
{{End}}
) {
{{ForEach(m.Properties, out p)}}
this.{{p.Name}} = {{p.PrivateName}};
{{End}}
}
{{End}}
}
""");
Console.WriteLine(result);
[ModelProxy]
public readonly record struct StructModel(string Name, PropertyModel[] Properties);
[ModelProxy]
public readonly record struct PropertyModel(string Type, string Name, string PrivateName);Output:
public readonly partial struct ReadOnlyIntVector3
{
public readonly int X;
public readonly int Y;
public readonly int Z;
public ReadOnlyIntVector3(
int x,
int y,
int z
) {
this.X = x;
this.Y = y;
this.Z = z;
}
}More patterns are available in the Recipes guide.
The benchmark generates Vector2 through Vector43 record structs and then runs FizzBuzz for 1 ≤ n < 43
(43 is the maximum that stays within Scriban's total loop-count limit).
Waffle template used
const int N = 43;
return Render($"""
{Note("Vector")}
{For(2, N + 1, out var i)}
public readonly record struct Vector{i}(
{For(0, i, out var k)}
float Value{k + 1}{(k == i - 1).To(b => b ? ");" : ",")}
{End}
{End}
{Note("FizzBuzz")}
{For(1, N, out i)}
{If(i % 15 == 0)}
FizzBuzz
{Elif(i % 3 == 0)}
Fizz
{Elif(i % 5 == 0)}
Buzz
{Else}
{i}
{End}
{End}
""");| Method | Mean | Error | StdDev | Ratio | RatioSD | Allocated | Alloc Ratio |
|---|---|---|---|---|---|---|---|
| T4 | 673,518.013 us | 9,043.6142 us | 8,459.4023 us | 138,006.44 | 3,469.61 | 359.72 KB | 9.44 |
| T4(preprocessed) | 7.936 us | 0.0341 us | 0.0302 us | 1.63 | 0.04 | 111.91 KB | 2.94 |
| Scriban | 173.724 us | 0.7521 us | 0.6280 us | 35.60 | 0.79 | 254.16 KB | 6.67 |
| Waffle | 29.869 us | 0.2518 us | 0.2355 us | 6.12 | 0.14 | 39.75 KB | 1.04 |
| StringBuilder | 4.883 us | 0.0943 us | 0.1123 us | 1.00 | 0.03 | 38.09 KB | 1.00 |
- T4 uses Mono.TextTemplating v3.0.0. The measurement covers the full pipeline — parsing a template defined as a string literal through to final string output (no file I/O).
- T4 (preprocessed) executes pre-generated
StringBuilder-based C# code produced bydotnet-t4 -c(parsing is done ahead of time). - Scriban uses v7.2.0. Like T4, the measurement covers parsing a template defined as a string literal through to final string output.
- Waffle and StringBuilder both use internal object pooling (
Token,StringBuilder, etc.), which keeps steady-state allocation low. The pools are warmed during BenchmarkDotNet's warmup phase, so the reported numbers reflect typical amortised performance after the first call.
Environment details
BenchmarkDotNet v0.15.8, Windows 11 (10.0.26200.8457/25H2/2025Update/HudsonValley2)
Intel Core Ultra 9 285K 3.70GHz, 1 CPU, 24 logical and 24 physical cores
.NET SDK 10.0.103
- [Host] : .NET 10.0.3 (10.0.3, 10.0.326.7603), X64 RyuJIT x86-64-v3
- DefaultJob : .NET 10.0.3 (10.0.3, 10.0.326.7603), X64 RyuJIT x86-64-v3
Third party notices
The benchmark project references Scriban (BSD-2-Clause), BenchmarkDotNet (MIT), and Mono.TextTemplating (MIT) via NuGet for comparison purposes only. None of these are dependencies of the published Waffle packages.
All library packages (Waffle.Core, Waffle.Bakery, Waffle.ModelProxy) target netstandard2.0, making them compatible with:
- .NET 5+ / .NET Core 2.0+
- .NET Framework 4.6.1+
- Roslyn Incremental Source Generators (which require netstandard2.0 assemblies)
- Unity (2018.1+)
The consuming project should support C# 11 or later (for interpolated string handler and raw string literal syntax).
| Document | Description |
|---|---|
| Syntax Reference | Complete reference for all WaffleSyntax commands, whitespace control, lazy resolution, and more |
| Recipes | Practical patterns — comma-separated lists, multi-line parameters, nested loops, and more |
| Waffle.Core README | Architecture, key types, extensibility points, and Source Generator integration guide |
| Waffle.ModelProxy README | [ModelProxy] setup, generated API, and advanced proxy usage |
| Waffle.Bakery README | Batch-execution framework, template registration, custom contexts |
| Waffle.Analyzer README | Compile-time diagnostic rules and their descriptions |
dotnet build -c Debug
dotnet test -c DebugThe solution requires .NET SDK 10.0+ to build. All test projects target net10.0.
No external runtime dependencies.
All files are licensed under the MIT License.
See the LICENSE file for details.
Contributions are welcome! Please read the Contribution Guide before opening an issue or pull request.
In short:
- Bugs / feature requests — open an issue first so it can be discussed.
- Pull requests — fork, branch from
main, keep the tests green (dotnet test -c Debug), and add tests for new behavior. - All pull requests are bound by our Contribution License Agreement.
See CONTRIBUTING.md for the full details, including the criteria used to evaluate feature requests.
