From facba638d35d7d35b37edfa1cfb3aef96f52a28b Mon Sep 17 00:00:00 2001 From: "Chris S." Date: Sun, 15 Jun 2025 19:06:09 +0100 Subject: [PATCH 1/2] Update README benchmark results --- docs/README.md | 16 ++++----- src/TextTemplate/Template.cs | 12 ++++--- src/TextTemplate/TemplateEngine.cs | 57 +++++++++++++++++++++++------- 3 files changed, 60 insertions(+), 25 deletions(-) diff --git a/docs/README.md b/docs/README.md index f5a7258..54731ec 100644 --- a/docs/README.md +++ b/docs/README.md @@ -207,10 +207,10 @@ The model contains five strings in the `Items` list so every engine performs a s | Method | Mean | Error | StdDev | |-------|------:|------:|------:| -| GoTextTemplate (.NET) | 14.52 us | 0.18 us | 0.15 us | -| Handlebars.Net | 1,857 us | 32 us | 29 us | -| Scriban | 14.62 us | 0.29 us | 0.81 us | -| DotLiquid | 13.79 us | 0.27 us | 0.28 us | +| GoTextTemplate (.NET) | 22.17 us | 13.37 us | 0.73 us | +| Handlebars.Net | 2,777.65 us | 2,581.13 us | 141.48 us | +| Scriban | 22.10 us | 13.73 us | 0.75 us | +| DotLiquid | 20.88 us | 38.64 us | 2.12 us | | Go text/template | 1.69 us | 0.00 us | 0.00 us | ### Advanced Scenario Benchmarks @@ -234,10 +234,10 @@ Example results on a small container: | Method | Mean | Error | StdDev | |-------|------:|------:|------:| -| GoTextTemplate_NET | 477.1 us | 371.94 us | 20.39 us | -| Handlebars | 47,455.5 us | 79,242.45 us | 4,343.55 us | -| Scriban | 202.1 us | 426.47 us | 23.38 us | -| DotLiquid | 467.4 us | 86.21 us | 4.73 us | +| GoTextTemplate_NET | 272.1 us | 317.5 us | 17.40 us | +| Handlebars | 63,860.9 us | 26,014.2 us | 1,425.93 us | +| Scriban | 286.3 us | 778.8 us | 42.69 us | +| DotLiquid | 728.0 us | 1,120.4 us | 61.41 us | | Go text/template | 0.79 us | 0.00 us | 0.00 us | ## Claude's suggestions diff --git a/src/TextTemplate/Template.cs b/src/TextTemplate/Template.cs index 4d61c9b..caecfbb 100644 --- a/src/TextTemplate/Template.cs +++ b/src/TextTemplate/Template.cs @@ -12,6 +12,7 @@ namespace TextTemplate; public class Template { private string _templateString = string.Empty; + private GoTextTemplateParser.TemplateContext? _parseTree; /// /// The template name. @@ -34,8 +35,7 @@ private Template(string name) public Template Parse(string templateString) { if (templateString == null) throw new ArgumentNullException(nameof(templateString)); - // Validate using the TemplateEngine so parsing rules remain consistent. - TemplateEngine.Validate(templateString); + _parseTree = TemplateEngine.Parse(templateString); _templateString = templateString; return this; } @@ -59,7 +59,9 @@ public Template ParseFiles(params string[] filenames) /// public string Execute(IDictionary data) { - return TemplateEngine.Process(_templateString, data); + if (_parseTree == null) + _parseTree = TemplateEngine.Parse(_templateString); + return TemplateEngine.Process(_parseTree, data); } /// @@ -67,7 +69,9 @@ public string Execute(IDictionary data) /// public string Execute(T model) { - return TemplateEngine.Process(_templateString, model); + if (_parseTree == null) + _parseTree = TemplateEngine.Parse(_templateString); + return TemplateEngine.Process(_parseTree, model); } /// diff --git a/src/TextTemplate/TemplateEngine.cs b/src/TextTemplate/TemplateEngine.cs index 569a813..1b86924 100644 --- a/src/TextTemplate/TemplateEngine.cs +++ b/src/TextTemplate/TemplateEngine.cs @@ -6,7 +6,9 @@ using System.Text.Encodings.Web; using System.Net; using Antlr4.Runtime; +using Antlr4.Runtime.Atn; using Antlr4.Runtime.Tree; +using Antlr4.Runtime.Misc; namespace TextTemplate; @@ -22,17 +24,8 @@ internal static class TemplateEngine /// public static string Process(string templateString, IDictionary data) { - templateString = PreprocessWhitespace(templateString); - templateString = PreprocessComments(templateString); - var inputStream = new AntlrInputStream(templateString); - var lexer = new GoTextTemplateLexer(inputStream); - var tokens = new CommonTokenStream(lexer); - var parser = new GoTextTemplateParser(tokens); - IParseTree tree = parser.template(); - - var templates = new Dictionary(); - var visitor = new ReplacementVisitor(data, templates); - return visitor.Visit(tree); + var tree = Parse(templateString); + return Process(tree, data); } /// @@ -41,13 +34,44 @@ public static string Process(string templateString, IDictionary public static void Validate(string templateString) { if (templateString == null) throw new ArgumentNullException(nameof(templateString)); + Parse(templateString); + } + + internal static GoTextTemplateParser.TemplateContext Parse(string templateString) + { templateString = PreprocessWhitespace(templateString); templateString = PreprocessComments(templateString); var inputStream = new AntlrInputStream(templateString); var lexer = new GoTextTemplateLexer(inputStream); var tokens = new CommonTokenStream(lexer); - var parser = new GoTextTemplateParser(tokens); - parser.template(); + var parser = new GoTextTemplateParser(tokens) + { + ErrorHandler = new BailErrorStrategy() + }; + parser.Interpreter.PredictionMode = PredictionMode.SLL; + try + { + return parser.template(); + } + catch (ParseCanceledException) + { + inputStream = new AntlrInputStream(templateString); + lexer = new GoTextTemplateLexer(inputStream); + tokens = new CommonTokenStream(lexer); + parser = new GoTextTemplateParser(tokens) + { + ErrorHandler = new DefaultErrorStrategy() + }; + parser.Interpreter.PredictionMode = PredictionMode.LL; + return parser.template(); + } + } + + internal static string Process(GoTextTemplateParser.TemplateContext tree, IDictionary data) + { + var templates = new Dictionary(); + var visitor = new ReplacementVisitor(data, templates); + return visitor.Visit(tree); } /// @@ -61,6 +85,13 @@ public static string Process(string templateString, T model) return Process(templateString, dict); } + internal static string Process(GoTextTemplateParser.TemplateContext tree, T model) + { + IDictionary dict = model as IDictionary ?? + ToDictionary(model!); + return Process(tree, dict); + } + private static string PreprocessWhitespace(string template) { var sb = new StringBuilder(); From 5eacbe64ca5fb455782a3af8f0399a2ea74496a0 Mon Sep 17 00:00:00 2001 From: Chris Date: Sun, 15 Jun 2025 19:49:16 +0100 Subject: [PATCH 2/2] Update benchmarks & readme --- benchmarks/TextTemplate.Benchmarks/Program.cs | 39 ----------- .../TemplateBenchmarks.cs | 39 +++++++++++ docs/README.md | 69 ++++++++++--------- 3 files changed, 76 insertions(+), 71 deletions(-) create mode 100644 benchmarks/TextTemplate.Benchmarks/TemplateBenchmarks.cs diff --git a/benchmarks/TextTemplate.Benchmarks/Program.cs b/benchmarks/TextTemplate.Benchmarks/Program.cs index feaec42..c8d570b 100644 --- a/benchmarks/TextTemplate.Benchmarks/Program.cs +++ b/benchmarks/TextTemplate.Benchmarks/Program.cs @@ -1,46 +1,7 @@ using System.Collections.Generic; -using BenchmarkDotNet.Attributes; using BenchmarkDotNet.Running; -using TextTemplate; using HandlebarsDotNet; using Scriban; -using DotLiquid; -using ScribanTemplateClass = Scriban.Template; -using DotLiquidTemplateClass = DotLiquid.Template; -using Hbs = HandlebarsDotNet.Handlebars; - -public class TemplateBenchmarks -{ - private const string TTTemplate = "Hello {{ .Name }}! {{ range .Items }}{{ . }} {{ end }}"; - private const string HBTemplate = "Hello {{Name}}! {{#each Items}}{{this}} {{/each}}"; - private const string ScribanTmpl = "Hello {{name}}! {{ for item in items }}{{item}} {{end}}"; - private const string DotLiquidTmpl = "Hello {{Name}}! {% for item in Items %}{{item}} {% endfor %}"; - - private Dictionary _model = null!; - - [GlobalSetup] - public void Setup() - { - _model = new Dictionary - { - ["Name"] = "Bob", - ["Items"] = new List { "one", "two", "three", "four", "five" } - }; - DotLiquidTemplateClass.NamingConvention = new DotLiquid.NamingConventions.CSharpNamingConvention(); - } - - [Benchmark] - public string GoTextTemplate() => TemplateEngine.Process(TTTemplate, _model); - - [Benchmark] - public string Handlebars() => Hbs.Compile(HBTemplate)(_model); - - [Benchmark] - public string Scriban() => ScribanTemplateClass.Parse(ScribanTmpl).Render(_model); - - [Benchmark] - public string DotLiquid() => DotLiquidTemplateClass.Parse(DotLiquidTmpl).Render(Hash.FromDictionary(_model)); -} public class Program { diff --git a/benchmarks/TextTemplate.Benchmarks/TemplateBenchmarks.cs b/benchmarks/TextTemplate.Benchmarks/TemplateBenchmarks.cs new file mode 100644 index 0000000..15145a9 --- /dev/null +++ b/benchmarks/TextTemplate.Benchmarks/TemplateBenchmarks.cs @@ -0,0 +1,39 @@ +using BenchmarkDotNet.Attributes; +using TextTemplate; +using DotLiquid; +using ScribanTemplateClass = Scriban.Template; +using DotLiquidTemplateClass = DotLiquid.Template; +using Hbs = HandlebarsDotNet.Handlebars; + +public class TemplateBenchmarks +{ + private const string TTTemplate = "Hello {{ .Name }}! {{ range .Items }}{{ . }} {{ end }}"; + private const string HBTemplate = "Hello {{Name}}! {{#each Items}}{{this}} {{/each}}"; + private const string ScribanTmpl = "Hello {{name}}! {{ for item in items }}{{item}} {{end}}"; + private const string DotLiquidTmpl = "Hello {{Name}}! {% for item in Items %}{{item}} {% endfor %}"; + + private Dictionary _model = null!; + + [GlobalSetup] + public void Setup() + { + _model = new Dictionary + { + ["Name"] = "Bob", + ["Items"] = new List { "one", "two", "three", "four", "five" } + }; + DotLiquidTemplateClass.NamingConvention = new DotLiquid.NamingConventions.CSharpNamingConvention(); + } + + [Benchmark] + public string GoTextTemplate() => TemplateEngine.Process(TTTemplate, _model); + + [Benchmark] + public string Handlebars() => Hbs.Compile(HBTemplate)(_model); + + [Benchmark] + public string Scriban() => ScribanTemplateClass.Parse(ScribanTmpl).Render(_model); + + [Benchmark] + public string DotLiquid() => DotLiquidTemplateClass.Parse(DotLiquidTmpl).Render(Hash.FromDictionary(_model)); +} diff --git a/docs/README.md b/docs/README.md index 54731ec..d0269fb 100644 --- a/docs/README.md +++ b/docs/README.md @@ -2,16 +2,27 @@ [![NuGet](https://img.shields.io/nuget/v/go-text-template.svg)](https://www.nuget.org/packages/go-text-template/) -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. -The source code in this repository was largely produced by Codex with input -from Claude.AI, and this README itself was also authored using Codex. +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#. + +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. + +This README itself was also largely authored using Codex. Internally the engine uses an ANTLR-generated lexer and parser. The original Go package can be found here: - https://pkg.go.dev/text/template#pkg-overview - https://cs.opensource.google/go/go/+/refs/tags/go1.24.4:src/text/template/template.go -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. +This library now contains virtually all functionality from the original Go text/template package. + +## Usage + +```csharp +var tmpl = Template.New("hello").Parse("Hello {{ .Name }}!"); +var result = tmpl.Execute(new { Name = "World" }); +Console.WriteLine(result); // Hello World! +``` + ## Features @@ -123,17 +134,8 @@ var result = Template.New("calc").Parse(template).Execute(new {}); ## Not Implemented Yet -- Custom functions beyond basic comparisons and boolean operators. - Custom delimiter support. -## Usage - -```csharp -var tmpl = Template.New("hello").Parse("Hello {{ .Name }}!"); -var result = tmpl.Execute(new { Name = "World" }); -Console.WriteLine(result); // Hello World! -``` - ### Example Template ```csharp @@ -197,21 +199,25 @@ See the unit tests for more examples covering loops, conditionals and range expr ## Benchmark Results -The following microbenchmarks were run using [BenchmarkDotNet](https://benchmarkdotnet.org/) on .NET 9.0. Each benchmark renders the same short template: +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: ```text Hello {{ .Name }}! {{ range .Items }}{{ . }} {{ end }} ``` +```bash +dotnet run -c Release --project benchmarks/TextTemplate.Benchmarks -- --filter "TemplateBenchmarks*" +``` + 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 warm‑up phase followed by enough iterations (13–96 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. -| Method | Mean | Error | StdDev | -|-------|------:|------:|------:| -| GoTextTemplate (.NET) | 22.17 us | 13.37 us | 0.73 us | -| Handlebars.Net | 2,777.65 us | 2,581.13 us | 141.48 us | -| Scriban | 22.10 us | 13.73 us | 0.75 us | -| DotLiquid | 20.88 us | 38.64 us | 2.12 us | -| Go text/template | 1.69 us | 0.00 us | 0.00 us | +| Method | Mean | Error | StdDev | +|--------------- |------------:|----------:|----------:| +| GoTextTemplate | 15.28 us | 0.238 us | 0.222 us | +| Handlebars.net | 1,721.10 us | 29.683 us | 26.313 us | +| Scriban | 15.36 us | 0.304 us | 0.482 us | +| DotLiquid | 12.98 us | 0.162 us | 0.151 us | +| Go text/template | 2,167 ns | (505,638 iterations ) | ### Advanced Scenario Benchmarks @@ -220,11 +226,10 @@ loads the Kubernetes-style YAML templates found under `tests/TestData` and executes them as a single nested template. Run the .NET benchmarks with: ```bash -dotnet run -c Release --project benchmarks/TextTemplate.Benchmarks -- --filter "*" +dotnet run -c Release --project benchmarks/TextTemplate.Benchmarks -- --filter "ComplexNestedTemplateBenchmarks*" ``` -BenchmarkDotNet will then execute both the basic and advanced scenarios. The Go -implementation can be benchmarked separately with: +The Go implementation can be benchmarked separately with: ```bash go test -bench BenchmarkGoComplexTemplate ./benchmarks/go -benchmem @@ -232,16 +237,16 @@ go test -bench BenchmarkGoComplexTemplate ./benchmarks/go -benchmem Example results on a small container: -| Method | Mean | Error | StdDev | -|-------|------:|------:|------:| -| GoTextTemplate_NET | 272.1 us | 317.5 us | 17.40 us | -| Handlebars | 63,860.9 us | 26,014.2 us | 1,425.93 us | -| Scriban | 286.3 us | 778.8 us | 42.69 us | -| DotLiquid | 728.0 us | 1,120.4 us | 61.41 us | -| Go text/template | 0.79 us | 0.00 us | 0.00 us | +| Method | Mean | Error | StdDev | +|------------------- |------------:|----------:|----------:| +| GoTextTemplate_NET | 172.0 us | 3.19 us | 2.98 us | +| Handlebars.net | 47,411.7 us | 679.98 us | 602.78 us | +| Scriban | 195.4 us | 3.07 us | 3.01 us | +| DotLiquid | 417.5 us | 6.07 us | 5.38 us | +| Go text/template | 2,167 ns | (1,218,702 iterations ) | ## Claude's suggestions -https://gist.github.com/yetanotherchris/c80d0fadb5a2ee5b4beb0a4384020dbf.js +https://gist.github.com/yetanotherchris/c80d0fadb5a2ee5b4beb0a4384020dbf ## License