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 f5a7258..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) | 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 | -| 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 | 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 | -| 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 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();