Skip to content

Commit 36f51ef

Browse files
Merge pull request #75 from yetanotherchris/codex/implement-antlr-performance-optimizations
Improve ANTLR parser handling
2 parents 27d267e + 5eacbe6 commit 36f51ef

5 files changed

Lines changed: 128 additions & 88 deletions

File tree

benchmarks/TextTemplate.Benchmarks/Program.cs

Lines changed: 0 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,46 +1,7 @@
11
using System.Collections.Generic;
2-
using BenchmarkDotNet.Attributes;
32
using BenchmarkDotNet.Running;
4-
using TextTemplate;
53
using HandlebarsDotNet;
64
using Scriban;
7-
using DotLiquid;
8-
using ScribanTemplateClass = Scriban.Template;
9-
using DotLiquidTemplateClass = DotLiquid.Template;
10-
using Hbs = HandlebarsDotNet.Handlebars;
11-
12-
public class TemplateBenchmarks
13-
{
14-
private const string TTTemplate = "Hello {{ .Name }}! {{ range .Items }}{{ . }} {{ end }}";
15-
private const string HBTemplate = "Hello {{Name}}! {{#each Items}}{{this}} {{/each}}";
16-
private const string ScribanTmpl = "Hello {{name}}! {{ for item in items }}{{item}} {{end}}";
17-
private const string DotLiquidTmpl = "Hello {{Name}}! {% for item in Items %}{{item}} {% endfor %}";
18-
19-
private Dictionary<string, object> _model = null!;
20-
21-
[GlobalSetup]
22-
public void Setup()
23-
{
24-
_model = new Dictionary<string, object>
25-
{
26-
["Name"] = "Bob",
27-
["Items"] = new List<string> { "one", "two", "three", "four", "five" }
28-
};
29-
DotLiquidTemplateClass.NamingConvention = new DotLiquid.NamingConventions.CSharpNamingConvention();
30-
}
31-
32-
[Benchmark]
33-
public string GoTextTemplate() => TemplateEngine.Process(TTTemplate, _model);
34-
35-
[Benchmark]
36-
public string Handlebars() => Hbs.Compile(HBTemplate)(_model);
37-
38-
[Benchmark]
39-
public string Scriban() => ScribanTemplateClass.Parse(ScribanTmpl).Render(_model);
40-
41-
[Benchmark]
42-
public string DotLiquid() => DotLiquidTemplateClass.Parse(DotLiquidTmpl).Render(Hash.FromDictionary(_model));
43-
}
445

456
public class Program
467
{
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
using BenchmarkDotNet.Attributes;
2+
using TextTemplate;
3+
using DotLiquid;
4+
using ScribanTemplateClass = Scriban.Template;
5+
using DotLiquidTemplateClass = DotLiquid.Template;
6+
using Hbs = HandlebarsDotNet.Handlebars;
7+
8+
public class TemplateBenchmarks
9+
{
10+
private const string TTTemplate = "Hello {{ .Name }}! {{ range .Items }}{{ . }} {{ end }}";
11+
private const string HBTemplate = "Hello {{Name}}! {{#each Items}}{{this}} {{/each}}";
12+
private const string ScribanTmpl = "Hello {{name}}! {{ for item in items }}{{item}} {{end}}";
13+
private const string DotLiquidTmpl = "Hello {{Name}}! {% for item in Items %}{{item}} {% endfor %}";
14+
15+
private Dictionary<string, object> _model = null!;
16+
17+
[GlobalSetup]
18+
public void Setup()
19+
{
20+
_model = new Dictionary<string, object>
21+
{
22+
["Name"] = "Bob",
23+
["Items"] = new List<string> { "one", "two", "three", "four", "five" }
24+
};
25+
DotLiquidTemplateClass.NamingConvention = new DotLiquid.NamingConventions.CSharpNamingConvention();
26+
}
27+
28+
[Benchmark]
29+
public string GoTextTemplate() => TemplateEngine.Process(TTTemplate, _model);
30+
31+
[Benchmark]
32+
public string Handlebars() => Hbs.Compile(HBTemplate)(_model);
33+
34+
[Benchmark]
35+
public string Scriban() => ScribanTemplateClass.Parse(ScribanTmpl).Render(_model);
36+
37+
[Benchmark]
38+
public string DotLiquid() => DotLiquidTemplateClass.Parse(DotLiquidTmpl).Render(Hash.FromDictionary(_model));
39+
}

docs/README.md

Lines changed: 37 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,27 @@
22

33
[![NuGet](https://img.shields.io/nuget/v/go-text-template.svg)](https://www.nuget.org/packages/go-text-template/)
44

5-
This project is a C# implementation of Go's template engine using ANTLR for parsing. It began as an experiment to see whether OpenAI Codex could port the Go implementation to .NET. Claude.AI helped with explanations and refinements along the way.
6-
The source code in this repository was largely produced by Codex with input
7-
from Claude.AI, and this README itself was also authored using Codex.
5+
text/template is a C# implementation of Go's template engine using ANTLR for parsing. It began as an experiment to see whether OpenAI Codex could port the Go implementation to .NET. Claude AI helped with explanations and refinements along the way, but the prompts were from my own knowledge of Antlr and grammars, and obviously C#.
6+
7+
It ported the Go code to begin with, but then I realised Antlr would probably be a safer and more readable alternative than a straight Go ---> C# port.
8+
9+
This README itself was also largely authored using Codex. Internally the engine uses an ANTLR-generated lexer and parser.
810

911
The original Go package can be found here:
1012

1113
- https://pkg.go.dev/text/template#pkg-overview
1214
- https://cs.opensource.google/go/go/+/refs/tags/go1.24.4:src/text/template/template.go
1315

14-
This library now contains virtually all functionality from the original Go text/template package. Parse templates with `Template.New("name").Parse(text)` and execute them with `Execute` to perform variable substitution, loops and conditionals. Internally the engine uses an ANTLR-generated lexer and parser.
16+
This library now contains virtually all functionality from the original Go text/template package.
17+
18+
## Usage
19+
20+
```csharp
21+
var tmpl = Template.New("hello").Parse("Hello {{ .Name }}!");
22+
var result = tmpl.Execute(new { Name = "World" });
23+
Console.WriteLine(result); // Hello World!
24+
```
25+
1526

1627
## Features
1728

@@ -123,17 +134,8 @@ var result = Template.New("calc").Parse(template).Execute(new {});
123134

124135
## Not Implemented Yet
125136

126-
- Custom functions beyond basic comparisons and boolean operators.
127137
- Custom delimiter support.
128138

129-
## Usage
130-
131-
```csharp
132-
var tmpl = Template.New("hello").Parse("Hello {{ .Name }}!");
133-
var result = tmpl.Execute(new { Name = "World" });
134-
Console.WriteLine(result); // Hello World!
135-
```
136-
137139
### Example Template
138140

139141
```csharp
@@ -197,21 +199,25 @@ See the unit tests for more examples covering loops, conditionals and range expr
197199

198200
## Benchmark Results
199201

200-
The following microbenchmarks were run using [BenchmarkDotNet](https://benchmarkdotnet.org/) on .NET 9.0. Each benchmark renders the same short template:
202+
The following benchmarks were run using [BenchmarkDotNet](https://benchmarkdotnet.org/) on .NET 9.0, `cpu: AMD Ryzen 7 5700X 8-Core Processor`. Each benchmark renders the same short template:
201203
202204
```text
203205
Hello {{ .Name }}! {{ range .Items }}{{ . }} {{ end }}
204206
```
205207

208+
```bash
209+
dotnet run -c Release --project benchmarks/TextTemplate.Benchmarks -- --filter "TemplateBenchmarks*"
210+
```
211+
206212
The model contains five strings in the `Items` list so every engine performs a small loop. BenchmarkDotNet ran each test using its default configuration which executes a warmup phase followed by enough iterations (1396 in our runs) to collect roughly one second of timing data. The Go implementation was benchmarked with `go test -bench .` using the equivalent template and data.
207213

208-
| Method | Mean | Error | StdDev |
209-
|-------|------:|------:|------:|
210-
| GoTextTemplate (.NET) | 14.52 us | 0.18 us | 0.15 us |
211-
| Handlebars.Net | 1,857 us | 32 us | 29 us |
212-
| Scriban | 14.62 us | 0.29 us | 0.81 us |
213-
| DotLiquid | 13.79 us | 0.27 us | 0.28 us |
214-
| Go text/template | 1.69 us | 0.00 us | 0.00 us |
214+
| Method | Mean | Error | StdDev |
215+
|--------------- |------------:|----------:|----------:|
216+
| GoTextTemplate | 15.28 us | 0.238 us | 0.222 us |
217+
| Handlebars.net | 1,721.10 us | 29.683 us | 26.313 us |
218+
| Scriban | 15.36 us | 0.304 us | 0.482 us |
219+
| DotLiquid | 12.98 us | 0.162 us | 0.151 us |
220+
| Go text/template | 2,167 ns | (505,638 iterations ) |
215221

216222
### Advanced Scenario Benchmarks
217223

@@ -220,28 +226,27 @@ loads the Kubernetes-style YAML templates found under `tests/TestData` and
220226
executes them as a single nested template. Run the .NET benchmarks with:
221227

222228
```bash
223-
dotnet run -c Release --project benchmarks/TextTemplate.Benchmarks -- --filter "*"
229+
dotnet run -c Release --project benchmarks/TextTemplate.Benchmarks -- --filter "ComplexNestedTemplateBenchmarks*"
224230
```
225231

226-
BenchmarkDotNet will then execute both the basic and advanced scenarios. The Go
227-
implementation can be benchmarked separately with:
232+
The Go implementation can be benchmarked separately with:
228233

229234
```bash
230235
go test -bench BenchmarkGoComplexTemplate ./benchmarks/go -benchmem
231236
```
232237

233238
Example results on a small container:
234239

235-
| Method | Mean | Error | StdDev |
236-
|-------|------:|------:|------:|
237-
| GoTextTemplate_NET | 477.1 us | 371.94 us | 20.39 us |
238-
| Handlebars | 47,455.5 us | 79,242.45 us | 4,343.55 us |
239-
| Scriban | 202.1 us | 426.47 us | 23.38 us |
240-
| DotLiquid | 467.4 us | 86.21 us | 4.73 us |
241-
| Go text/template | 0.79 us | 0.00 us | 0.00 us |
240+
| Method | Mean | Error | StdDev |
241+
|------------------- |------------:|----------:|----------:|
242+
| GoTextTemplate_NET | 172.0 us | 3.19 us | 2.98 us |
243+
| Handlebars.net | 47,411.7 us | 679.98 us | 602.78 us |
244+
| Scriban | 195.4 us | 3.07 us | 3.01 us |
245+
| DotLiquid | 417.5 us | 6.07 us | 5.38 us |
246+
| Go text/template | 2,167 ns | (1,218,702 iterations ) |
242247

243248
## Claude's suggestions
244-
https://gist.github.com/yetanotherchris/c80d0fadb5a2ee5b4beb0a4384020dbf.js
249+
https://gist.github.com/yetanotherchris/c80d0fadb5a2ee5b4beb0a4384020dbf
245250
246251
## License
247252

src/TextTemplate/Template.cs

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ namespace TextTemplate;
1212
public class Template
1313
{
1414
private string _templateString = string.Empty;
15+
private GoTextTemplateParser.TemplateContext? _parseTree;
1516

1617
/// <summary>
1718
/// The template name.
@@ -34,8 +35,7 @@ private Template(string name)
3435
public Template Parse(string templateString)
3536
{
3637
if (templateString == null) throw new ArgumentNullException(nameof(templateString));
37-
// Validate using the TemplateEngine so parsing rules remain consistent.
38-
TemplateEngine.Validate(templateString);
38+
_parseTree = TemplateEngine.Parse(templateString);
3939
_templateString = templateString;
4040
return this;
4141
}
@@ -59,15 +59,19 @@ public Template ParseFiles(params string[] filenames)
5959
/// </summary>
6060
public string Execute(IDictionary<string, object> data)
6161
{
62-
return TemplateEngine.Process(_templateString, data);
62+
if (_parseTree == null)
63+
_parseTree = TemplateEngine.Parse(_templateString);
64+
return TemplateEngine.Process(_parseTree, data);
6365
}
6466

6567
/// <summary>
6668
/// Executes the template using the public properties of <typeparamref name="T"/>.
6769
/// </summary>
6870
public string Execute<T>(T model)
6971
{
70-
return TemplateEngine.Process(_templateString, model);
72+
if (_parseTree == null)
73+
_parseTree = TemplateEngine.Parse(_templateString);
74+
return TemplateEngine.Process(_parseTree, model);
7175
}
7276

7377
/// <summary>

src/TextTemplate/TemplateEngine.cs

Lines changed: 44 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@
66
using System.Text.Encodings.Web;
77
using System.Net;
88
using Antlr4.Runtime;
9+
using Antlr4.Runtime.Atn;
910
using Antlr4.Runtime.Tree;
11+
using Antlr4.Runtime.Misc;
1012

1113
namespace TextTemplate;
1214

@@ -22,17 +24,8 @@ internal static class TemplateEngine
2224
/// </summary>
2325
public static string Process(string templateString, IDictionary<string, object> data)
2426
{
25-
templateString = PreprocessWhitespace(templateString);
26-
templateString = PreprocessComments(templateString);
27-
var inputStream = new AntlrInputStream(templateString);
28-
var lexer = new GoTextTemplateLexer(inputStream);
29-
var tokens = new CommonTokenStream(lexer);
30-
var parser = new GoTextTemplateParser(tokens);
31-
IParseTree tree = parser.template();
32-
33-
var templates = new Dictionary<string, GoTextTemplateParser.ContentContext>();
34-
var visitor = new ReplacementVisitor(data, templates);
35-
return visitor.Visit(tree);
27+
var tree = Parse(templateString);
28+
return Process(tree, data);
3629
}
3730

3831
/// <summary>
@@ -41,13 +34,44 @@ public static string Process(string templateString, IDictionary<string, object>
4134
public static void Validate(string templateString)
4235
{
4336
if (templateString == null) throw new ArgumentNullException(nameof(templateString));
37+
Parse(templateString);
38+
}
39+
40+
internal static GoTextTemplateParser.TemplateContext Parse(string templateString)
41+
{
4442
templateString = PreprocessWhitespace(templateString);
4543
templateString = PreprocessComments(templateString);
4644
var inputStream = new AntlrInputStream(templateString);
4745
var lexer = new GoTextTemplateLexer(inputStream);
4846
var tokens = new CommonTokenStream(lexer);
49-
var parser = new GoTextTemplateParser(tokens);
50-
parser.template();
47+
var parser = new GoTextTemplateParser(tokens)
48+
{
49+
ErrorHandler = new BailErrorStrategy()
50+
};
51+
parser.Interpreter.PredictionMode = PredictionMode.SLL;
52+
try
53+
{
54+
return parser.template();
55+
}
56+
catch (ParseCanceledException)
57+
{
58+
inputStream = new AntlrInputStream(templateString);
59+
lexer = new GoTextTemplateLexer(inputStream);
60+
tokens = new CommonTokenStream(lexer);
61+
parser = new GoTextTemplateParser(tokens)
62+
{
63+
ErrorHandler = new DefaultErrorStrategy()
64+
};
65+
parser.Interpreter.PredictionMode = PredictionMode.LL;
66+
return parser.template();
67+
}
68+
}
69+
70+
internal static string Process(GoTextTemplateParser.TemplateContext tree, IDictionary<string, object> data)
71+
{
72+
var templates = new Dictionary<string, GoTextTemplateParser.ContentContext>();
73+
var visitor = new ReplacementVisitor(data, templates);
74+
return visitor.Visit(tree);
5175
}
5276

5377
/// <summary>
@@ -61,6 +85,13 @@ public static string Process<T>(string templateString, T model)
6185
return Process(templateString, dict);
6286
}
6387

88+
internal static string Process<T>(GoTextTemplateParser.TemplateContext tree, T model)
89+
{
90+
IDictionary<string, object> dict = model as IDictionary<string, object> ??
91+
ToDictionary(model!);
92+
return Process(tree, dict);
93+
}
94+
6495
private static string PreprocessWhitespace(string template)
6596
{
6697
var sb = new StringBuilder();

0 commit comments

Comments
 (0)